Compare commits

...
101 Commits
Author SHA1 Message Date
Flowseal 18175fb4fe Version bump 2026-10-06 00:56:50 +03:00
Flowseal f742a8957d Correct handle of the 404 mtproto error 2026-10-06 00:56:20 +03:00
Flowseal 3686c36462 fixes #1392 2026-10-05 12:47:53 +03:00
Flowseal 4bdd69ade9 version bump 2026-10-05 02:31:01 +03:00
Flowseal 50c980e9ea WsPool refactoring, TCP fallback backoff, removed direct connections outside of a pool 2026-10-04 21:53:26 +03:00
Flowseal 48954ff9ab multiplexing media through cfproxy 2026-10-04 15:57:33 +03:00
Flowseal fe1580cd83 remove old docs path support 2026-10-04 15:12:26 +03:00
Flowseal ad7f3cae9e UI refactoring 2026-09-25 16:30:14 +03:00
Flowseal caa949bee0 fixes #1357 fixes #1354 2026-09-22 21:26:53 +03:00
Flowseal 3d59c813aa typo 2026-09-22 20:57:11 +03:00
Flowseal 70b982da2c Bump version 2026-09-19 22:51:47 +03:00
Flowseal 5226a409d4 clarify dialog message 2026-09-19 22:49:57 +03:00
Flowseal e519c4df4c use certifi for tls connections, fixes #1348 2026-09-19 22:48:25 +03:00
Flowseal a0605c7447 removed debug print closes #1350 2026-09-19 22:41:37 +03:00
Flowseal 222cabeee0 Clarify naming 2026-09-19 11:32:10 +03:00
Flowseal c2da669576 Bump metadata version 2026-09-19 11:27:50 +03:00
Flowseal 2c9a0b8a90 Bump version 2026-09-19 11:26:32 +03:00
Flowseal 87977962dc Disable SSL switch 2026-09-19 11:25:12 +03:00
Flowseal 5a61d8f782 Simplify passing None to --dc-ip 2026-09-18 17:30:35 +03:00
Flowseal f200e33fd2 version bump 2026-09-07 16:32:42 +03:00
Flowseal c02398fbc0 censoring domains 2026-09-05 16:23:24 +03:00
Flowseal 5f55245184 fix linux build 2026-09-05 15:39:45 +03:00
Flowseal 312d844eea removed old contribution link 2026-09-05 00:04:15 +03:00
Flowseal be485a3851 version bump 2026-09-04 17:49:40 +03:00
Flowseal f811a10918 don't close update dialog on release page open 2026-09-04 17:36:15 +03:00
Flowseal 55ec6a91df remove topmost 2026-09-04 17:35:41 +03:00
Flowseal b23c8f6122 #1213 fixes 2026-09-04 17:22:30 +03:00
Konukhov Yaroslav d36bb7df71 feat: add manual update triggers to settings and tray menu (#1213) 2026-09-04 17:13:56 +03:00
Flowseal bd4f3d080c todo text correction 2026-09-03 20:20:41 +03:00
Flowseal 492d999248 closes #1232 2026-09-03 20:17:46 +03:00
Flowseal e8ad0d6958 refill pool after rotating expired connections 2026-09-03 17:25:21 +03:00
Flowseal 5eb7e007d3 try ws_pool if connection is timed out 2026-09-03 17:09:18 +03:00
Flowseal 1ae2f3a147 todo #1232 2026-09-03 05:14:03 +03:00
Flowseal d2b6c450eb TODOs 2026-09-03 05:11:03 +03:00
Matt Van Horn 1b8671b3d3 fix: restore Linux Mint 21 binary compatibility (#1238)
Fixes #1144
2026-09-01 18:27:13 +03:00
Flowseal b2a8074c59 version bump 2026-08-13 14:48:53 +03:00
delewer 0d459995f3 chore: generic fixes (#1187) 2026-08-13 14:44:09 +03:00
Flowseal b8a5634008 fix contributing path 2026-08-07 22:40:35 +03:00
Flowseal de4179305c docs duplicate for comp 2026-08-07 22:33:36 +03:00
Fallout e3958984c4 Добавить двуязычную документацию (русский и английский) (#1101) 2026-08-07 21:14:52 +03:00
Flowseal 3e7e266176 fix (macOS): crash fix on settings open 2026-08-07 20:29:26 +03:00
Flowseal fba86856db unpin certifi version 2026-08-07 14:04:13 +03:00
partoftheworlD 8b05ba79ae Fix 9f9e1c2 (#1183) 2026-08-07 14:02:54 +03:00
Flowseal 9f9e1c2482 MacOS moved to tkinter and pystray; cetifi implementation to fix SSL issues 2026-08-07 00:41:20 +03:00
Flowseal 2496004c93 move locales path to be handled by pyinstaller 2026-08-04 15:36:50 +03:00
Flowseal b7ff77f550 tooltips moved to customtkinter; closes #1176 2026-08-04 15:24:09 +03:00
Flowseal 2995ae7436 Save user domains instead of erasing; closes #1177 2026-08-04 15:02:18 +03:00
Flowseal 02e52da3a7 removed unused config var 2026-08-04 13:44:56 +03:00
Flowseal 2a699bb91b Version bump 2026-07-31 16:53:31 +03:00
Flowseal a5b2b73dfd Updated icon 2026-07-31 16:52:17 +03:00
Flowseal aee473c9f9 CF Worker pool refactoring 2026-07-31 16:38:42 +03:00
Flowseal 41e97c6a05 hard limit workers pool 2026-07-31 14:20:58 +03:00
Flowseal ecf3d6f3a1 Fixes #1161 fixes #1155 2026-07-31 13:41:23 +03:00
Flowseal 21aaeb3aba macos build fix 2026-07-28 11:43:28 +03:00
Flowseal e0230ebda7 Version bump 2026-07-28 11:33:42 +03:00
Flowseal 47b8db18d9 Added pool refill backoff; don't refill pool on .get if ip is blocked 2026-07-28 11:08:24 +03:00
Flowseal a0545cca64 don't retry fronting connect 2026-07-28 10:55:36 +03:00
Flowseal 129a760996 fixes #1079 #1083 2026-07-28 10:41:39 +03:00
Flowseal 8ac52f69b3 auto wspool rotation 2026-07-20 02:38:57 +03:00
Flowseal 34cfd8cf22 pool fronting 2026-07-19 16:02:49 +03:00
Flowseal 88b7b55c58 fronting refactoring: implemented into ws_pool 2026-07-19 15:28:57 +03:00
Flowseal 57b538389c docs update 2026-07-09 23:23:15 +03:00
Flowseal eb00122821 github bot 2026-07-08 14:15:41 +03:00
Flowseal e81f0a973d fixes #1113 2026-07-08 00:43:13 +03:00
Flowseal 3143eb7147 docs upd 2026-07-07 21:57:06 +03:00
Danil c3309ed11b Поддержка тестового окружения Telegram (тестовые DC) (#1087) 2026-07-07 21:56:24 +03:00
Flowseal e9b74d7d7d optional art builds 2026-06-30 19:15:53 +03:00
Flowseal 050dcbce44 fixes #1072 #1060 2026-06-30 19:11:01 +03:00
Flowseal 4f9edf3072 Version bump 2026-06-27 01:22:19 +03:00
Flowseal 9ff95d1222 ip_timeout implementation 2026-06-27 01:19:27 +03:00
Flowseal 4c19a6cce4 release footer separator 2026-06-26 19:35:25 +03:00
Flowseal bb900e0c9e todo 2026-06-26 19:33:48 +03:00
Flowseal 1cdbca8893 Version bump 2026-06-26 19:03:19 +03:00
Flowseal 0df9174ce2 dc tip correction for en locale 2026-06-26 19:01:14 +03:00
Flowseal 7fc24fea95 todos 2026-06-26 18:59:37 +03:00
Flowseal 5c40fa2574 faster scroll 2026-06-26 18:51:55 +03:00
Flowseal c8f6f8caf4 Fronting fallback 2026-06-26 18:37:30 +03:00
Flowseal d3d30799a1 watchdog adjustments 2026-06-26 16:54:02 +03:00
Flowseal a0806d5a22 i18n fixes 2026-06-26 16:52:56 +03:00
Flowseal eb22fadb7a watchdog as possible fix winerror 64 2026-06-26 16:44:13 +03:00
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
82 changed files with 9201 additions and 2105 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
custom: ['https://nowpayments.io/donation/flowseal']
+5
View File
@@ -13,3 +13,8 @@ khgrre.com
ulihssf.com ulihssf.com
tmhqsdqmfpmk.com tmhqsdqmfpmk.com
xwuwoqbm.com xwuwoqbm.com
orgcnunpj.com
zhkuldz.com
zypoljnslxa.com
efabnxaowuzs.com
zaftuzsftqdq.com
+2
View File
@@ -0,0 +1,2 @@
trusted_users:
- Flowseal
+162 -44
View File
@@ -12,13 +12,39 @@ on:
description: "Release version tag (e.g. v1.0.0)" description: "Release version tag (e.g. v1.0.0)"
required: false required: false
default: "v1.0.0" default: "v1.0.0"
build_windows_x64:
description: 'Build Windows x64?'
type: boolean
required: false
default: true
build_windows_arm64:
description: 'Build Windows ARM64?'
type: boolean
required: false
default: true
build_win7:
description: 'Build Windows 7 (x64 + x86)?'
type: boolean
required: false
default: true
build_macos:
description: 'Build macOS universal?'
type: boolean
required: false
default: true
build_linux:
description: 'Build Linux (amd64/.deb/.rpm)?'
type: boolean
required: false
default: true
permissions: permissions:
contents: write contents: write
jobs: jobs:
build-windows: build-windows-x64:
runs-on: windows-latest runs-on: windows-latest
if: ${{ github.event.inputs.build_windows_x64 == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -73,11 +99,89 @@ 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
if: ${{ github.event.inputs.build_windows_arm64 == 'true' }}
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
if: ${{ github.event.inputs.build_win7 == 'true' }}
strategy: strategy:
matrix: matrix:
include: include:
@@ -131,6 +235,9 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
if: ${{ github.event.inputs.build_macos == 'true' }}
env:
CFFI_VERSION: "2.0.0"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -156,7 +263,7 @@ jobs:
--python-version 3.12 \ --python-version 3.12 \
--implementation cp \ --implementation cp \
-d wheelhouse/arm64 \ -d wheelhouse/arm64 \
'cffi>=2.0.0' \ "cffi==$CFFI_VERSION" \
Pillow==12.1.0 \ Pillow==12.1.0 \
psutil==7.0.0 psutil==7.0.0
@@ -166,7 +273,7 @@ jobs:
--python-version 3.12 \ --python-version 3.12 \
--implementation cp \ --implementation cp \
-d wheelhouse/x86_64 \ -d wheelhouse/x86_64 \
'cffi>=2.0.0' \ "cffi==$CFFI_VERSION" \
Pillow==12.1.0 Pillow==12.1.0
python3.12 -m pip download \ python3.12 -m pip download \
@@ -196,30 +303,16 @@ jobs:
python3.12 -m pip install . python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0 python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO - name: Validate macOS GUI dependencies
run: |
python3.12 -m pip check
python3.12 -m py_compile macos.py
python3.12 -c "import AppKit, customtkinter, macos, pystray, tkinter"
- 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
@@ -227,6 +320,12 @@ 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"
test -d "dist/TG WS Proxy.app/Contents/Resources/customtkinter"
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
@@ -250,22 +349,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
@@ -274,7 +382,8 @@ jobs:
path: dist/TgWsProxy_macos_universal.dmg path: dist/TgWsProxy_macos_universal.dmg
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -286,6 +395,8 @@ jobs:
python3-venv \ python3-venv \
python3-dev \ python3-dev \
python3-gi \ python3-gi \
python3-gi-cairo \
gir1.2-appindicator3-0.1 \
gir1.2-ayatanaappindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \
python3-tk python3-tk
@@ -439,9 +550,15 @@ jobs:
dist/TgWsProxy_linux_amd64.rpm 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'
&& github.event.inputs.build_windows_x64 == 'true'
&& github.event.inputs.build_windows_arm64 == 'true'
&& github.event.inputs.build_win7 == 'true'
&& github.event.inputs.build_macos == 'true'
&& github.event.inputs.build_linux == 'true' }}
steps: steps:
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v8
with: with:
@@ -455,7 +572,7 @@ 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: |
## ---
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md) ### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
> [!TIP] > [!TIP]
@@ -463,6 +580,7 @@ jobs:
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/ > Добавьте `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
+6 -4
View File
@@ -15,7 +15,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi "httpx[http2]==0.28.1"
FROM python:3.12-slim AS runtime FROM python:3.12-slim AS runtime
@@ -25,7 +25,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
TG_WS_PROXY_HOST=0.0.0.0 \ TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_SECRET="" \ TG_WS_PROXY_SECRET="" \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
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 \
@@ -36,11 +37,12 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy COPY proxy ./proxy
COPY utils ./utils
COPY docs/README.md LICENSE ./ COPY docs/README.md LICENSE ./
USER app 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; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] 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 []
+15 -1
View File
@@ -37,12 +37,26 @@ pip install -e .
Подробности: `docs/BuildFromSource.md`. Подробности: `docs/BuildFromSource.md`.
## Проверки
Тесты используют только стандартную библиотеку, дополнительные зависимости не нужны:
```bash
python -m unittest discover -s tests -t .
```
Линтер (`ruff` настроен в `pyproject.toml`):
```bash
ruff check .
```
## Pull Request ## Pull Request
Перед открытием PR: Перед открытием PR:
1. Убедитесь, что изменение решает конкретную проблему. 1. Убедитесь, что изменение решает конкретную проблему.
2. Проверьте, что не сломаны существующие сценарии. 2. Проверьте, что не сломаны существующие сценарии: запустите тесты и линтер.
3. Обновите документацию, если меняется поведение или настройка. 3. Обновите документацию, если меняется поведение или настройка.
Небольшие и сфокусированные PR проверяются и принимаются быстрее. Небольшие и сфокусированные PR проверяются и принимаются быстрее.
+79
View File
@@ -0,0 +1,79 @@
# Building from Source
## Console Proxy
To run only the proxy without the system tray interface, basic installation is sufficient:
```bash
pip install -e .
tg-ws-proxy
```
## Tray Application by OS
### Windows 7/10+
```bash
pip install -e .
tg-ws-proxy-tray-win
```
### macOS
Requires a Python build with Tk support. You can verify it with the command `python3 -m tkinter`.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
### Linux
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
## Console Mode from Source
```bash
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```
**Arguments:**
| Argument | Default | Description |
|---|---|---|
| `--port` | `1443` | Proxy port |
| `--host` | `127.0.0.1` | Proxy host |
| `--secret` | `random` | 32-character hex key for client authorization |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Target IP for DC (can be specified multiple times) |
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--no-h2` | `false` | Disable HTTP/2 media multiplexing through CF proxy |
| `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. |
| `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. |
| `--no-secure` | `false` | Use 80 port for CF-proxy and CF-worker connections |
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--buf-kb` | `256` | Buffer size in KB |
| `--pool-size` | `4` | Number of ready WS connections per DC. `0` disables the direct DC→IP WS route |
| `--log-file` | disabled | Path to file for saving logs |
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
| `--log-backups` | `0` | Number of log backups after overwrite |
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
**Examples:**
```bash
# Standard startup
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Different port and additional DCs
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# With verbose logging, without direct connections to DCs
tg-ws-proxy -v --dc-ip
# Fake TLS masquerading (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
```
+62
View File
@@ -0,0 +1,62 @@
# CONTRIBUTING
Thank you for wanting to help the `tg-ws-proxy` project.
## Before Creating an Issue
1. Check the documentation in `docs/README.md`.
2. Make sure a similar issue hasn't already been opened.
3. Use standard labels from `.github/labels.md` for correct triage.
## How to Report Problems
- Use the `Problem` template.
- If possible, provide:
- Application version,
- Operating system,
- Steps to reproduce,
- Expected and actual behavior,
- Log file or error text.
The more precise your description, the faster we can help.
## Local Development from Source
Python `>=3.8` is required.
```bash
pip install -e .
```
Running:
- console mode: `tg-ws-proxy`
- Windows tray: `tg-ws-proxy-tray-win`
- macOS tray: `tg-ws-proxy-tray-macos`
- Linux tray: `tg-ws-proxy-tray-linux`
Details: `docs/BuildFromSource.md`.
## Checks
Tests use the standard library only, no extra dependencies:
```bash
python -m unittest discover -s tests -t .
```
Linting (`ruff` is configured in `pyproject.toml`):
```bash
ruff check .
```
## Pull Request
Before opening a PR:
1. Make sure your change solves a specific problem.
2. Check that existing scenarios aren't broken; run the tests and the linter.
3. Update documentation if behavior or configuration changes.
Smaller and focused PRs are reviewed and accepted faster.
+37
View File
@@ -0,0 +1,37 @@
# Cloudflare Proxy
An alternative, free connection method is proxying through Cloudflare, which can be used for unreachable data centers. **All you need to get it working is a domain**. The application includes a default domain, but it can (and ideally should) be replaced with your own.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block. If the CF proxy works, media will start loading again.
## Why should I set up my own domain?
Cloudflare limits the number of simultaneous WebSocket (WS) connections. The default domain could stop working at any moment.
## Setting up your own domain
1. Add your domain to Cloudflare (either by purchasing it directly from Cloudflare or by changing the NS servers: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Domains cost around $1.50–$2.00 per year, and any domain extension will work.
2. In `SSL/TLS` → `Overview`, set the mode to **Flexible**.
3. In `DNS` → `Records`, add the following `A` records via `+ Add Record`:
- Name=`kws1` IPv4=`149.154.175.50`
- Name=`kws2` IPv4=`149.154.167.51`
- Name=`kws3` IPv4=`149.154.175.100`
- Name=`kws4` IPv4=`149.154.167.91`
- Name=`kws5` IPv4=`149.154.171.5`
- Name=`kws203` IPv4=`91.105.192.100`
4. **Add your domain to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software, as the Cloudflare subnet may be blocked (e.g., in Russia).**
5. In the `TgWsProxy` settings, replace the default domain with your own.
## HTTP/2 media multiplexing
Multiplexing is enabled by default. Media connections share one TCP/TLS connection to Cloudflare per DC and domain, using `/api` instead of `/apiws`. Main connections and test DCs continue using WS. H2 requires CF proxy and TLS enabled.
Can be disabled with `--no-h2` or by unchecking the **Media multiplexing (HTTP/2)** in the **Cloudflare Proxy** settings.
## Credits / Acknowledgments
- Original Idea: https://github.com/Nekogram/WSProxy
- Special thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for providing the information.
+129
View File
@@ -0,0 +1,129 @@
# Cloudflare Worker
An alternative (completely free, no domain purchase required unlike [CfProxy](./CfProxy.md)) method for proxying.
The proxy restores access to content that previously wouldn't load (reactions, certain stickers). If you are using a non-Premium account with this method and photos/videos still fail to load, leave only `4:149.154.167.220` in the `DC → IP` block.
##
1. **Add the following domains to [zapret](https://github.com/Flowseal/zapret-discord-youtube/) or any other DPI bypass software:**
```
cloudflare.com
cloudflare.dev
workers.dev
```
2. Create an account on [Cloudflare](https://dash.cloudflare.com/) (or log into an existing one)
* **After creating your account, verify your email using the link sent to your inbox**
3. Select `Compute` → `Workers & Pages` from the left panel
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
4. Click the **`Create application`** button in the top right → `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. Click the **`Edit code`** button in the top right, then replace the code on the left with the one [found at the bottom of this page](#worker-code)
* If the code section fails to load, it means you missed the first step
<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. Click the **`Deploy`** button in the top right
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
7. Copy the domain from the field on the right and specify it in your **Cloudflare Worker** settings (or via the `--cfproxy-worker-domain` argument)
* Example domain: `random-symbols-1234.username.workers.dev`
* **You can specify multiple domains separated by commas (or by repeating the `--cfproxy-worker-domain` argument)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
### Worker Code
```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 in Nginx
The domain in the `--fake-tls-domain` parameter should point to the same IP where the proxy is running.
## Example `nginx.conf` for Stream Module
```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;
}
```
## Running Proxy Behind 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>
```
The connection link will be in `ee`-secret format:
```text
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
```
+11
View File
@@ -0,0 +1,11 @@
> [!TIP]
>
> ### 🎉 Support Me
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment.
I would appreciate any form of support! Thank you ❤️
+70
View File
@@ -0,0 +1,70 @@
# TG WS Proxy for Docker
## Installation from Source
Enter the commands sequentially, one by one:
```bash
# Clone the repository
git clone https://github.com/Flowseal/tg-ws-proxy.git
# Navigate to the project folder
cd tg-ws-proxy
# Build the image
docker build -t tg-ws-proxy .
# Run the container
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
tg-ws-proxy:latest
# Get the connection link
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
```
After running the last command, you will see a link like:
```text
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
```
## Configuring Parameters
All settings are configured using environment variables when running the container:
| Variable | Description | Default |
| ----------------------- | -------------------------------- | --------------------------------- |
| `TG_WS_PROXY_HOST` | Address for incoming connections | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Port inside the container | `1443` |
| `TG_WS_PROXY_SECRET` | Secret key | `random` |
| `TG_WS_PROXY_DC_IPS` | DC number:IP pairs separated by space | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | Cloudflare Worker domain | `None` |
Example with manually specified secret:
```bash
docker run -d \
--name tg-ws-proxy \
--restart=always \
-p 1443:1443 \
-e TG_WS_PROXY_SECRET="your_secret" \
tg-ws-proxy:latest
```
To generate a secret, you can use:
```bash
openssl rand -hex 16
```
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
+51
View File
@@ -0,0 +1,51 @@
# TG WS Proxy for Linux
## Prebuilt Packages
For Debian/Ubuntu, download the `TgWsProxy_linux_amd64.deb` package from the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases).
For Arch and Arch-based distributions, packages are available in 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
# Installation without AUR helper
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
cd tg-ws-proxy-bin
makepkg -si
# Using AUR helper
paru -S tg-ws-proxy-bin
# For -cli package, run via systemd (8888 — port number; secret can be generated with openssl rand -hex 16)
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
```
For other distributions, you can use `TgWsProxy_linux_amd64` (binary for x86_64).
```bash
chmod +x TgWsProxy_linux_amd64
./TgWsProxy_linux_amd64
```
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-linux
```
+32
View File
@@ -0,0 +1,32 @@
# TG WS Proxy for macOS
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download `TgWsProxy_macos_universal.dmg` (universal build for Apple Silicon and Intel).
1. Open the image
2. Drag `TG WS Proxy.app` to the `Applications` folder
3. On first launch, macOS may ask for confirmation: **System Settings → Privacy & Security → Open Anyway**
Minimum supported versions:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
The interface requires Tk, CustomTkinter, and access to Cocoa via PyObjC. They are installed automatically, except for Tk, which must be included in your Python build.
```bash
pip install -e .
tg-ws-proxy-tray-macos
```
+144
View File
@@ -0,0 +1,144 @@
<div align="center">
**[🇷🇺 Русский](../README.md) • 🇬🇧 English**
</div>
<div align="center">
<br />
<p>
<img width="1729" height="910" alt="tgwsproxy" src="../images/workflow.png" />
</p>
</div>
##
> [!TIP]
>
> ### [🎉 Support Me](../EN/Funding.md)
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> [!CAUTION]
>
> ### Antivirus Detection
>
> Antivirus software sometimes incorrectly marks the application as a virus due to the packer.
> If you cannot download due to antivirus blocking, then:
>
> 1) **Try downloading the Windows 7 version (functionally identical)**
> 2) Temporarily disable antivirus during download, add the file to exclusions, then re-enable
>
> Always verify what you download from the internet, especially from untrusted sources. It's best to check detections from well-known antivirus vendors on VirusTotal.
# TG WS Proxy
**Local MTProto proxy** for Telegram Desktop that **speeds up Telegram**, redirecting traffic through WebSocket connections. Data is transmitted in the same encrypted form, and no external servers are needed.
<picture>
<source srcset="../images/preview-dark.png" media="(prefers-color-scheme: dark)">
<img src="../images/preview-white.png">
</picture>
## Navigation
- **🚀 Quick Start**
- **[Windows](./README.windows.md)**
- **[macOS](./README.macos.md)**
- **[Linux](./README.linux.md)**
- **[Docker](./README.docker.md)**
- [Cloudflare Worker Setup (free alternative to CF proxy)](./CfWorker.md)
- [Cloudflare Domain Setup (CF proxy)](./CfProxy.md)
- [Telegram Test Environment (Test DCs)](./TestDc.md)
- [Fake TLS + upstream in Nginx](./FakeTlsNginx.md)
- [Tray Application Configuration Files](./TrayConfig.md)
- [Building from Source](./BuildFromSource.md)
- [Contributor Guide](./CONTRIBUTING.md)
## Windows: Quick Start
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `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)
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
### Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
### Configuring Telegram Desktop
**Automatic Setup**
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
**Manual Setup**
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## How It Works
```
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
1. Application starts MTProto proxy on `127.0.0.1:1443`
2. Intercepts connections to Telegram IP addresses
3. Extracts DC ID from MTProto obfuscation init packet
4. Establishes WebSocket connection (TLS) to corresponding DC via Telegram domains
5. If WS unavailable (302 redirect) — automatically switches to CfProxy / direct TCP connection
> [!IMPORTANT]
> ### Photos/Videos Not Loading?
> **In proxy settings, leave only `4:149.154.167.220` in DC → IP**
> **If that doesn't work, clear the field completely**
> This issue occurs on non-Premium accounts
> If still not working, set up your own domain following: [CfProxy.md](./CfProxy.md)
## Automatic Build
The project contains PyInstaller specs ([`packaging/windows.spec`](../../packaging/windows.spec), [`packaging/macos.spec`](../../packaging/macos.spec), [`packaging/linux.spec`](../../packaging/linux.spec)) and GitHub Actions workflow ([`.github/workflows/build.yml`](../../.github/workflows/build.yml)) for automated builds.
Minimum supported OS versions for current binary builds:
- Windows 10+ x64 for `TgWsProxy_windows.exe`
- Windows 10+ ARM64 for `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) for `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) for `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (AppIndicator required for system tray)
## Contributors
Thanks to everyone who helps develop this project ❤️
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
</a>
## License
[MIT License](../../LICENSE)
+58
View File
@@ -0,0 +1,58 @@
# TG WS Proxy for Windows
Go to the [releases page](https://github.com/Flowseal/tg-ws-proxy/releases) and download:
- `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)
Builds are published automatically via [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) from open source code.
On first launch, a window will open with instructions for connecting Telegram Desktop. **The application minimizes to system tray.**
## Tray Menu
- **Open in Telegram** — automatically configure proxy via `tg://proxy` link
- **Copy Link** — copy the proxy connection link
- **Restart Proxy** — restart without exiting the application
- **Settings...** — GUI configuration editor (app version, optional GitHub update checks)
- **Open Logs** — open log file
- **Exit** — stop proxy and close application
On first launch after startup, you may be prompted to open the release page if a new version is available on GitHub (this check can be disabled in settings).
## Configuring Telegram Desktop
### Automatic Setup
Right-click the tray icon and select **"Open in Telegram"**.
If it doesn't work (Telegram doesn't open with proxy), follow these steps:
1. Right-click the tray icon and select **"Copy Link"**
2. Send the link to "Saved Messages" in Telegram and click it
3. Connect
### Manual Setup
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
2. Add proxy:
- **Type:** MTProto
- **Server:** `127.0.0.1` (or your custom address)
- **Port:** `1443` (or your custom port)
- **Secret:** from settings or logs
## Portable Mode
Portable mode is automatically enabled if a folder named `TgWsProxy_data` exists next to the executable.
You can also force portable mode by running the executable with the `--portable` parameter (it will create the folder).
## Building from Source
Detailed instructions: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-win
```
+23
View File
@@ -0,0 +1,23 @@
# Telegram Test Environment (Test DCs)
Traffic routing to Telegram test data centers (test environment).
Useful for developing/testing bots and clients within the Telegram test environment.
## How to Enable
**Automatically.** Telegram Desktop marks test DCs with a +10000
offset (10001–10003). The proxy automatically detects this offset — no configuration needed, allowing
you to use production and test accounts simultaneously in a single client.
**Forced.** For clients that report test DCs as standard 1-3
(Telethon, TDLib) — they cannot be detected automatically. In this case, all traffic
is forcibly routed to test DCs (production accounts will stop working through this proxy).
To force this behavior, use the `--force-test-dc` flag in CLI:
```bash
tg-ws-proxy --force-test-dc # + your --secret / --port
```
## Limitations
Only works for **direct DC → IP** and **Cloudflare Worker** routes (see [Setting up a Cloudflare Worker](./CfWorker.md)).
+32
View File
@@ -0,0 +1,32 @@
# Tray Application Configuration Files
The tray application stores data in:
- **Windows:** `%APPDATA%/TgWsProxy`
- **macOS:** `~/Library/Application Support/TgWsProxy`
- **Linux:** `~/.config/TgWsProxy` (or `$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": "",
"force_test_dc": false,
"appearance": "auto"
}
```
The `check_updates` key: when `true`, performs a request to GitHub and compares the current version with the latest release (notification and link to download page only).
On Windows, the config may contain `autostart` (auto-start on system login).
+23 -15
View File
@@ -1,3 +1,9 @@
<div align="center">
**🇷🇺 Русский • [🇬🇧 English](./EN/README.md)**
</div>
<div align="center"> <div align="center">
<br /> <br />
<p> <p>
@@ -9,12 +15,11 @@
> [!TIP] > [!TIP]
> >
> ### [🎉 Поддержать меня](./Funding.md) > ### [🎉 Поддержать меня](./RU/Funding.md)
> >
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
@@ -40,22 +45,24 @@
## Навигация ## Навигация
- **🚀 Быстрый старт** - **🚀 Быстрый старт**
- **[Windows](./README.windows.md)** - **[Windows](./RU/README.windows.md)**
- **[macOS](./README.macos.md)** - **[macOS](./RU/README.macos.md)**
- **[Linux](./README.linux.md)** - **[Linux](./RU/README.linux.md)**
- **[Docker](./README.docker.md)** - **[Docker](./RU/README.docker.md)**
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md) - [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./RU/CfWorker.md)
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md) - [Настройка Cloudflare-домена (CF-прокси)](./RU/CfProxy.md)
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md) - [Тестовое окружение Telegram (тестовые DC)](./RU/TestDc.md)
- [Файлы конфигурации Tray-приложения](./TrayConfig.md) - [Fake TLS + upstream в Nginx](./RU/FakeTlsNginx.md)
- [Установка из исходников](./BuildFromSource.md) - [Файлы конфигурации Tray-приложения](./RU/TrayConfig.md)
- [Руководство для контрибьюторов](../CONTRIBUTING.md) - [Установка из исходников](./RU/BuildFromSource.md)
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
## Windows: быстрый вход ## Windows: быстрый вход
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -108,7 +115,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`** > **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
> **Если это не помогло, полностью очистите это поле** > **Если это не помогло, полностью очистите это поле**
> Подобная проблема встречается на аккаунтах без Premium > Подобная проблема встречается на аккаунтах без Premium
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md) > Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./RU/CfProxy.md)
## Автоматическая сборка ## Автоматическая сборка
@@ -116,7 +123,8 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- 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+
@@ -20,6 +20,8 @@ tg-ws-proxy-tray-win
### macOS ### macOS
Требуется сборка Python с поддержкой Tk. Проверить её можно командой `python3 -m tkinter`.
```bash ```bash
pip install -e . pip install -e .
tg-ws-proxy-tray-macos tg-ws-proxy-tray-macos
@@ -47,12 +49,14 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов | | `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) | | `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--no-h2` | `false` | Отключить мультиплексирование медиа через HTTP/2 в CF-прокси |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. | | `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. | | `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом | | `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) | | `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ | | `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC | | `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи | | `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) | | `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи | | `--log-backups` | `0` | Количество сохранений логов после перезаписи |
@@ -62,13 +66,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```bash ```bash
# Стандартный запуск # Стандартный запуск
tg-ws-proxy tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Другой порт и дополнительные DC # Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием # С подробным логированием, без прямого подключения к DC
tg-ws-proxy -v tg-ws-proxy -v --dc-ip
# Fake TLS маскировка (ee-secret) # Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com tg-ws-proxy --fake-tls-domain example.com
+6
View File
@@ -26,6 +26,12 @@ Cloudflare имеет лимиты на одновременное количе
5. В настройках `TgWsProxy` замените домен на свой. 5. В настройках `TgWsProxy` замените домен на свой.
## Мультиплексирование медиа через HTTP/2
Мультиплексирование включено по умолчанию. Медиа-соединения используют `/api` вместо `/apiws` через одно общее TCP/TLS-соединение до Cloudflare для каждого DC и домена. Основные соединения и тестовые DC продолжают использовать WS.
Чтобы отключить мультиплексирование, добавьте `--no-h2` или снимите галочку
**Мультиплексирование медиа (HTTP/2)** в настройках **Cloudflare Proxy**.
## Благодарности ## Благодарности
- Идея: https://github.com/Nekogram/WSProxy - Идея: https://github.com/Nekogram/WSProxy
+2 -1
View File
@@ -32,7 +32,8 @@ workers.dev
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" /> <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`) 7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
* Пример домена: `random-symbols-1234.username.workers.dev` * Пример домена: `random-symbols-1234.username.workers.dev`
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" /> <img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
File renamed without changes.
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех. Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений. Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
@@ -35,12 +35,13 @@ tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
Все настройки задаются переменными окружения при запуске контейнера: Все настройки задаются переменными окружения при запуске контейнера:
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|-----------------------|------------------------------------------------|--------------------------------------| | ----------------------- | --------------------------------- | ------------------------------------- |
| `TG_WS_PROXY_HOST` | Адрес для приёма подключений | `0.0.0.0` | | `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Порт внутри контейнера | `1443` | | `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
| `TG_WS_PROXY_SECRET` | Секретный ключ | `random` | | `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_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
Пример с ручным указанием секрета: Пример с ручным указанием секрета:
File renamed without changes.
@@ -24,6 +24,8 @@
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md) Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
Для интерфейса требуются Tk, CustomTkinter и доступ к Cocoa через PyObjC. Они устанавливаются автоматически, кроме Tk, который должен входить в используемую сборку Python.
```bash ```bash
pip install -e . pip install -e .
tg-ws-proxy-tray-macos tg-ws-proxy-tray-macos
@@ -2,7 +2,8 @@
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -42,6 +43,10 @@
- **Порт:** `1443` (или переопределенный вами) - **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов - **Secret:** из настроек или логов
## Портативный режим
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
## Установка из исходников ## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md) Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
+23
View File
@@ -0,0 +1,23 @@
# Тестовое окружение Telegram (тестовые DC)
Маршрутизация трафика к тестовым дата-центрам Telegram (test environment).
Нужна для разработки/тестирования ботов и клиентов в тестовой среде Telegram.
## Как включается
**Автоматически.** Telegram Desktop помечает тестовые DC сдвигом +10000
(10001–10003). Прокси распознаёт сдвиг сам — включать ничего не нужно, можно
одновременно пользоваться продовым и тестовым аккаунтом в одном клиенте.
**Принудительно.** Для клиентов, которые сообщают тестовые DC как обычные 1-3
(Telethon, TDLib) — распознать их автоматически нельзя. Тогда весь трафик
принудительно направляется на тестовые DC (продовые аккаунты через этот прокси
работать перестанут). Для принудительной работы используйте флаг `--force-test-dc` в CLI:
```bash
tg-ws-proxy --force-test-dc # + ваши --secret / --port
```
## Ограничения
Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)).
@@ -23,6 +23,7 @@ Tray-приложение хранит данные в:
"cfproxy": true, "cfproxy": true,
"cfproxy_user_domain": "", "cfproxy_user_domain": "",
"cfproxy_worker_domain": "", "cfproxy_worker_domain": "",
"force_test_dc": false,
"appearance": "auto" "appearance": "auto"
} }
``` ```
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 22 KiB

+81 -66
View File
@@ -24,12 +24,13 @@ from utils.tray_common import (
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
) )
from ui.ctk_theme import ( 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 t
from ui.settings_dialog import SettingsDialog
_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,59 +147,35 @@ 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,
after_create=_apply_window_icon, theme=theme, topmost=False, 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)
_original_appearance = ctk.get_appearance_mode() def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
)
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None: dialog = SettingsDialog(
ctk.set_appearance_mode(_original_appearance) ctk=ctk, root=root, widgets=widgets, config=_config,
_finish() defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=_finish,
restart=lambda config: restart_proxy(config, _show_error),
)
def on_save() -> None: root.protocol("WM_DELETE_WINDOW", dialog.cancel)
from tkinter import messagebox install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
return
_ui_only_keys = {"appearance", "check_updates"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_finish()
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build) ctk_run_dialog(_build)
@@ -224,8 +199,8 @@ 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, topmost=False, after_create=_apply_window_icon,
) )
def on_done(open_tg: bool) -> None: def on_done(open_tg: bool) -> None:
@@ -248,14 +223,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),
) )
@@ -283,7 +258,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()
@@ -291,9 +266,49 @@ def run_tray() -> None:
log.info("Tray app exited") log.info("Tray app exited")
def _smoke_test() -> None:
"""Exercise the frozen GUI stack without starting the proxy or writing config."""
import gi
gi.require_version('AppIndicator3', '0.1')
gi.require_version('AyatanaAppIndicator3', '0.1')
from gi.repository import AppIndicator3, AyatanaAppIndicator3
# Namespace imports alone do not resolve app_indicator_new: call it too.
indicators = [
namespace.Indicator.new(
'tg-ws-proxy-smoke-test', '', namespace.IndicatorCategory.APPLICATION_STATUS,
)
for namespace in (AppIndicator3, AyatanaAppIndicator3)
]
icon = pystray.Icon('tg-ws-proxy-smoke-test', Image.new('RGB', (16, 16)))
root = ctk.CTk()
root.withdraw()
root.update_idletasks()
root.destroy()
# A successful constructor on the build host can hide missing bundled
# libraries. Check where the dynamic loader actually obtained them.
bundle_dir = os.path.realpath(sys._MEIPASS) + os.sep
prefixes = ('libglib-', 'libgobject-', 'libgio-', 'libgtk-',
'libappindicator', 'libayatana-', 'libdbusmenu-')
with open('/proc/self/maps', encoding='utf-8') as maps:
paths = {line.split(maxsplit=5)[-1].strip() for line in maps}
for path in sorted(paths):
if os.path.basename(path).startswith(prefixes):
if not os.path.realpath(path).startswith(bundle_dir):
raise RuntimeError('GUI library loaded outside the bundle: ' + path)
print(path)
assert icon is not None and all(indicators)
print('Linux bundle smoke test passed')
def main() -> None: def main() -> None:
if sys.argv[1:] == ['--smoke-test']:
_smoke_test()
return
if not acquire_lock(): 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()
+424 -537
View File
File diff suppressed because it is too large. Load diff
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"
+44 -15
View File
@@ -1,33 +1,34 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import sys
import os import os
import glob import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files from PyInstaller.utils.hooks import collect_data_files
block_cipher = None block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled # customtkinter ships JSON themes + assets that must be bundled
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
# Collect gi (PyGObject) submodules and data so pystray._appindicator works _i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi')
# Collect GObject typelib files from the system appindicator_binaries = [
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0') (path, '.')
typelib_datas = [] for pattern in ('/usr/lib/*/libappindicator3.so.1',
for d in typelib_dirs: '/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
typelib_datas.append((d, 'gi_typelibs')) for path in glob.glob(pattern)
]
a = Analysis( 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=appindicator_binaries,
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas, datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[ hiddenimports=[
'httpx',
'h2',
'pystray._appindicator', 'pystray._appindicator',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
'customtkinter', 'customtkinter',
@@ -36,15 +37,22 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers.modes', 'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl', 'cryptography.hazmat.backends.openssl',
'gi', 'gi',
'_gi',
'gi.repository.GLib', 'gi.repository.GLib',
'gi.repository.GObject', 'gi.repository.GObject',
'gi.repository.Gtk', 'gi.repository.Gtk',
'gi.repository.Gdk', 'gi.repository.Gdk',
'gi.repository.DBus',
'gi.repository.AppIndicator3',
'gi.repository.AyatanaAppIndicator3', 'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports, ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={
'gi': {
'icons': [],
'themes': [],
'languages': ['en', 'ru'],
},
},
runtime_hooks=[], runtime_hooks=[],
excludes=[ excludes=[
'PIL._avif', 'PIL._avif',
@@ -55,6 +63,27 @@ a = Analysis(
cipher=block_cipher, cipher=block_cipher,
) )
_required_libraries = {
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
'libgtk-3.so.0', 'libappindicator3.so.1',
'libayatana-appindicator3.so.1',
}
_required_typelibs = {
'AppIndicator3-0.1.typelib', 'AyatanaAppIndicator3-0.1.typelib', 'DBus-1.0.typelib',
}
_bundled_libraries = {
os.path.basename(name)
for name, _, kind in a.binaries + a.datas
if kind in ('BINARY', 'SYMLINK')
}
_missing = (
_required_libraries - _bundled_libraries
) | (
_required_typelibs - {os.path.basename(name) for name, _, _ in a.datas}
)
if _missing:
raise RuntimeError('Incomplete Linux GI bundle: ' + ', '.join(sorted(_missing)))
_PIL_EXCLUDE_PYDS = { _PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk', '_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin', 'FpxImagePlugin', 'MicImagePlugin',
+17 -9
View File
@@ -1,22 +1,33 @@
# -*- mode: python ; coding: utf-8 -*-
import sys import sys
import os import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None block_cipher = None
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_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=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[ hiddenimports=[
'rumps', 'httpx',
'h2',
'tkinter',
'customtkinter',
'pystray._darwin',
'PIL._tkinter_finder',
'objc', 'objc',
'Foundation', 'Foundation',
'AppKit', 'AppKit',
'PyObjCTools', 'PyObjCTools',
'PyObjCTools.AppHelper', 'PyObjCTools.MachSignals',
'cryptography.hazmat.primitives.ciphers', 'cryptography.hazmat.primitives.ciphers',
'cryptography.hazmat.primitives.ciphers.algorithms', 'cryptography.hazmat.primitives.ciphers.algorithms',
'cryptography.hazmat.primitives.ciphers.modes', 'cryptography.hazmat.primitives.ciphers.modes',
@@ -28,14 +39,13 @@ a = Analysis(
excludes=[ excludes=[
'PIL._avif', 'PIL._avif',
'PIL._webp', 'PIL._webp',
'PIL._imagingtk',
], ],
noarchive=False, noarchive=False,
cipher=block_cipher, cipher=block_cipher,
) )
_PIL_EXCLUDE_PYDS = { _PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk', '_avif', '_webp',
'FpxImagePlugin', 'MicImagePlugin', 'FpxImagePlugin', 'MicImagePlugin',
} }
a.binaries = [ a.binaries = [
@@ -91,7 +101,5 @@ app = BUNDLE(
'LSMinimumSystemVersion': '10.15', 'LSMinimumSystemVersion': '10.15',
'LSUIElement': True, 'LSUIElement': True,
'NSHighResolutionCapable': True, 'NSHighResolutionCapable': True,
'NSAppleEventsUsageDescription':
'TG WS Proxy needs to display dialogs.',
}, },
) )
+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, 7, 2, 0), filevers=(1, 11, 1, 0),
prodvers=(1, 7, 2, 0), prodvers=(1, 11, 1, 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.7.2.0'), StringStruct(u'FileVersion', u'1.11.1.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.7.2.0'), StringStruct(u'ProductVersion', u'1.11.1.0'),
] ]
) )
] ]
+8 -1
View File
@@ -3,18 +3,25 @@
import sys import sys
import os import os
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled # customtkinter ships JSON themes + assets that must be bundled
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
_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')] + certifi_datas,
hiddenimports=[ hiddenimports=[
'httpx',
'h2',
'pystray._win32', 'pystray._win32',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
'customtkinter', 'customtkinter',
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.7.2" __version__ = "1.11.1"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+139 -51
View File
@@ -1,9 +1,9 @@
import asyncio import asyncio
import logging import logging
import struct import struct
import random import time
from typing import List, Optional from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlencode from urllib.parse import urlencode
from .utils import * from .utils import *
@@ -13,11 +13,25 @@ from .config import proxy_config
from .raw_websocket import RawWebSocket from .raw_websocket import RawWebSocket
from .pool import cf_worker_pool from .pool import cf_worker_pool
from ._aes import Cipher, algorithms, modes from ._aes import Cipher, algorithms, modes
from .network_debug import WsActivity
from .cf_h2 import bridge_h2
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I') _st_I_le = struct.Struct('<I')
TCP_BACKOFF_INITIAL = 30.0
TCP_BACKOFF_MAX = 3600.0
_tcp_failures: Dict[Tuple[str, int], int] = {}
_tcp_retry_after: Dict[Tuple[str, int], float] = {}
_tcp_connecting: Set[Tuple[str, int]] = set()
def reset_tcp_backoff() -> None:
_tcp_failures.clear()
_tcp_retry_after.clear()
_tcp_connecting.clear()
ZERO_64 = b'\x00' * 64 ZERO_64 = b'\x00' * 64
@@ -130,10 +144,11 @@ class MsgSplitter:
async def do_fallback(reader, writer, relay_init, label, async def do_fallback(reader, writer, relay_init, label,
dc: int, is_media: bool, media_tag: str, dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None): ctx: CryptoCtx, splitter=None, *, h2_pool=None, proto_tag=None):
fallback_dst = DC_DEFAULT_IPS.get(dc) ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
use_cf = proxy_config.fallback_cfproxy fallback_dst = ip_table.get(dc)
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
worker_domains = proxy_config.cfproxy_worker_domains worker_domains = proxy_config.cfproxy_worker_domains
methods: List[str] = [] methods: List[str] = []
@@ -149,11 +164,18 @@ async def do_fallback(reader, writer, relay_init, label,
if method == 'cf_worker' and fallback_dst: if method == 'cf_worker' and fallback_dst:
ok = await _cfproxy_worker_fallback( ok = await _cfproxy_worker_fallback(
reader, writer, relay_init, label, ctx, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, fallback_dst=fallback_dst, dc=dc, is_test_dc=is_test_dc, is_media=is_media,
splitter=splitter) fallback_dst=fallback_dst, splitter=splitter)
if ok: if ok:
return True return True
elif method == 'cf': elif method == 'cf':
if is_media and h2_pool is not None and proto_tag is not None:
channel = await h2_pool.open(dc, label)
if channel is not None:
stats.connections_h2 += 1
stats.connections_cfproxy += 1
await bridge_h2(reader, writer, channel, ctx, proto_tag)
return True
ok = await _cfproxy_fallback( ok = await _cfproxy_fallback(
reader, writer, relay_init, label, ctx, reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media, dc=dc, is_media=is_media,
@@ -161,8 +183,6 @@ async def do_fallback(reader, writer, relay_init, label,
if ok: if ok:
return True return True
elif method == 'tcp' and fallback_dst: elif method == 'tcp' and fallback_dst:
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback( ok = await _tcp_fallback(
reader, writer, fallback_dst, 443, reader, writer, fallback_dst, 443,
relay_init, label, ctx) relay_init, label, ctx)
@@ -173,46 +193,52 @@ async def do_fallback(reader, writer, relay_init, label,
async def _cfproxy_worker_fallback(reader, writer, relay_init, label, async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
ctx: CryptoCtx, ctx: CryptoCtx,
dc: int, is_media: bool, dc: int, is_test_dc: bool, is_media: bool,
fallback_dst: str, fallback_dst: str,
splitter=None): splitter=None):
media_tag = ' media' if is_media else '' media_tag = ' media' if is_media else ''
worker_domains = proxy_config.cfproxy_worker_domains worker_domains = proxy_config.cfproxy_worker_domains
if not worker_domains: if not worker_domains:
return False return False
random.shuffle(worker_domains)
for worker_domain in worker_domains: pooled = None if is_test_dc else await cf_worker_pool.get(
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst) dc, fallback_dst, worker_domains)
if ws: if pooled:
log.info("[%s] DC%d%s -> CF worker pool hit for %s", ws, worker_domain = pooled
label, dc, media_tag, fallback_dst) log.info("[%s] DC%d%s -> CF worker pool hit via %s for %s",
else: label, dc, media_tag, worker_domain, fallback_dst)
query = urlencode({ else:
'dst': fallback_dst, query = urlencode({
'dc': str(dc), 'dst': fallback_dst,
}) 'dc': str(dc),
path = f'/apiws?{query}' })
path = f'/apiws?{query}'
ws = None
for worker_domain in cf_worker_pool.available_domains(worker_domains):
log.info("[%s] DC%d%s -> trying CF worker %s for %s", log.info("[%s] DC%d%s -> trying CF worker %s for %s",
label, dc, media_tag, worker_domain, fallback_dst) label, dc, media_tag, worker_domain, fallback_dst)
try: try:
ws = await RawWebSocket.connect(worker_domain, worker_domain, ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path) timeout=10.0, path=path,
secure=not proxy_config.disable_secure)
break
except Exception as exc: except Exception as exc:
cf_worker_pool.report_failure(worker_domain, exc)
log.warning("[%s] DC%d%s CF worker %s failed: %s", log.warning("[%s] DC%d%s CF worker %s failed: %s",
label, dc, media_tag, worker_domain, repr(exc)) label, dc, media_tag, worker_domain, repr(exc))
continue continue
stats.connections_cfproxy += 1 if ws is None:
await ws.send(relay_init) return False
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
dc=dc, is_media=is_media, stats.connections_cfproxy += 1
splitter=splitter) await ws.send(relay_init)
return True await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
return False dc=dc, is_media=is_media,
splitter=None)
return True
async def _cfproxy_fallback(reader, writer, relay_init, label, async def _cfproxy_fallback(reader, writer, relay_init, label,
@@ -225,11 +251,13 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
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)
connect_started = time.monotonic()
for base_domain in balancer.get_domains_for_dc(dc): 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,
secure=not proxy_config.disable_secure)
chosen_domain = base_domain chosen_domain = base_domain
break break
except Exception as exc: except Exception as exc:
@@ -239,6 +267,8 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
if ws is None: if ws is None:
return False return False
log.debug('[%s] WS CONNECT DC%d%s route=cf setup_ms=%.0f',
label, dc, media_tag, (time.monotonic() - connect_started) * 1000)
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain): if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
log.info("[%s] Switched active CF domain", label) log.info("[%s] Switched active CF domain", label)
@@ -251,19 +281,45 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx): async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
try: key = (dst, port)
rr, rw = await asyncio.wait_for( if key in _tcp_connecting or time.monotonic() < _tcp_retry_after.get(key, 0):
asyncio.open_connection(dst, port), timeout=10) log.debug("[%s] TCP fallback to %s:%d skipped (backoff or connecting)",
except Exception as exc: label, dst, port)
log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, repr(exc))
return False return False
stats.connections_tcp_fallback += 1 _tcp_connecting.add(key)
rw.write(relay_init) rw = None
await rw.drain() try:
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx) try:
return True log.info("[%s] TCP fallback to %s:%d", label, dst, port)
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
rw.write(relay_init)
await rw.drain()
except Exception as exc:
failures = _tcp_failures.get(key, 0) + 1
_tcp_failures[key] = failures
delay = min(TCP_BACKOFF_INITIAL * 2 ** min(failures - 1, 7),
TCP_BACKOFF_MAX)
_tcp_retry_after[key] = time.monotonic() + delay
log.warning("[%s] TCP fallback to %s:%d failed: %r; retry in %.0fs",
label, dst, port, exc, delay)
return False
finally:
_tcp_connecting.discard(key)
_tcp_failures.pop(key, None)
_tcp_retry_after.pop(key, None)
stats.connections_tcp_fallback += 1
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
return True
finally:
if rw is not None:
rw.close()
try:
await rw.wait_closed()
except (OSError, ConnectionError):
pass
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label, async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
@@ -282,9 +338,11 @@ 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'
activity = WsActivity(label, dc_tag) if log.isEnabledFor(logging.DEBUG) else None
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)
@@ -292,9 +350,15 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
if splitter: if splitter:
tail = splitter.flush() tail = splitter.flush()
if tail: if tail:
if activity is not None:
activity.send_started()
await ws.send(tail[0]) await ws.send(tail[0])
if activity is not None:
activity.sent(len(tail[0]))
break break
n = len(chunk) n = len(chunk)
if activity is not None:
activity.input(n)
stats.bytes_up += n stats.bytes_up += n
up_bytes += n up_bytes += n
up_packets += 1 up_packets += 1
@@ -304,25 +368,40 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
parts = splitter.split(chunk) parts = splitter.split(chunk)
if not parts: if not parts:
continue continue
if activity is not None:
activity.send_started()
if len(parts) > 1: if len(parts) > 1:
await ws.send_batch(parts) await ws.send_batch(parts)
else: else:
await ws.send(parts[0]) await ws.send(parts[0])
if activity is not None:
activity.sent(sum(map(len, parts)))
else: else:
if activity is not None:
activity.send_started()
await ws.send(chunk) await ws.send(chunk)
except (asyncio.CancelledError, ConnectionError, OSError): if activity is not None:
activity.sent(len(chunk))
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)
if activity is not None:
activity.received(n)
stats.bytes_down += n stats.bytes_down += n
down_bytes += n down_bytes += n
down_packets += 1 down_packets += 1
@@ -330,9 +409,16 @@ 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): if activity is not None:
activity.delivered(n)
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()),
@@ -348,9 +434,11 @@ 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: " if activity is not None:
activity.close()
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)
@@ -410,4 +498,4 @@ async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
w.close() w.close()
await w.wait_closed() await w.wait_closed()
except BaseException: except BaseException:
pass pass
+985
View File
@@ -0,0 +1,985 @@
from __future__ import annotations
import asyncio
import logging
import os
import struct
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
import httpx
from .balancer import balancer
from .h2_transport import H2Transport
from .stats import stats
from .utils import (
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
PROTO_TAG_SECURE, create_ssl_context,
)
log = logging.getLogger('tg-mtproto-proxy')
MAX_PACKET = 4 * 1024 * 1024
MAX_CHANNEL_REQUESTS = 8
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
MAX_LANE_REQUESTS = 64
MAX_LANE_BYTES = 32 * 1024 * 1024
MAX_LANE_REPLY_BYTES = 32 * 1024 * 1024
DOMAIN_COOLDOWN = 30.0
REQUEST_TIMEOUT = 65.0
SETUP_TIMEOUT = 8.0
REPLAY_IDLE_SECONDS = 1.0
REPLAY_REQUEST_SECONDS = 3.0
REPLAY_CHECK_SECONDS = 0.25
REPLAY_MAX_AGE = 30.0
REPLAY_HISTORY_BYTES = 64 * 1024
REPLAY_HISTORY_PACKETS = 16
MAX_CHANNEL_REPLAYS = 2
REPLAY_SLOT_SECONDS = 30.0
REPLAY_RETRY_SECONDS = 2.0
REPLAY_MAX_ATTEMPTS = 3
SLOW_RESPONSE_SECONDS = 3.0
DIAGNOSTIC_INTERVAL = 5.0
@dataclass
class _RequestTrace:
packet: int
started: float
replay: bool
size: int
request: int = 0
stream: int = 0
phase: str = 'queued'
sent_at: Optional[float] = None
headers_at: Optional[float] = None
finished_at: Optional[float] = None
received: int = 0
cf_ray: str = '-'
def summary(self, now: float) -> str:
end = self.finished_at if self.finished_at is not None else now
headers = self.headers_at if self.headers_at is not None else end
send_ms = -1 if self.sent_at is None else (self.sent_at - self.started) * 1000
headers_ms = -1 if self.sent_at is None else max(0, headers - self.sent_at) * 1000
return ('req=%d pkt=%d sid=%d replay=%d phase=%s age_ms=%.0f '
'send_ms=%.0f wait_headers_ms=%.0f up=%d down=%d ray=%s' % (
self.request, self.packet, self.stream, self.replay, self.phase,
(end - self.started) * 1000,
send_ms, headers_ms,
self.size, self.received, self.cf_ray))
class _NativeDeliveryError(ConnectionError):
"""The local receiver disconnected"""
class _ReplyBufferFull(BufferError):
"""A local memory limit"""
class _MTProtoTransportError(Exception):
"""An endpoint error that the native client must receive, not just EOF."""
def __init__(self, code: int, source: str):
super().__init__('%d (%s)' % (code, source))
self.code = code
@dataclass
class _ReplayPacket:
sent_at: float
body: bytes
sequence: int
attempts: int = 0
last_attempt_at: float = 0.0
pending: Optional[asyncio.Task] = None
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
retired: bool = False
@property
def replayed(self) -> bool:
return self.attempts > 0
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
async def plaintext(count: int) -> bytes:
return decryptor.update(await reader.readexactly(count))
if tag == PROTO_TAG_ABRIDGED:
first = (await plaintext(1))[0]
quick, words = bool(first & 0x80), first & 0x7f
if words == 0x7f:
words = int.from_bytes(await plaintext(3), 'little')
length = words * 4
else:
value, = struct.unpack('<I', await plaintext(4))
quick, length = bool(value & 0x80000000), value & 0x7fffffff
if not 24 <= length <= MAX_PACKET + (15 if tag == PROTO_TAG_SECURE else 0):
raise ValueError('native packet length outside supported range: %d' % length)
body = await plaintext(length)
if tag == PROTO_TAG_SECURE:
if body[:8] == b'\x00' * 8:
packet_length = 20 + int.from_bytes(body[16:20], 'little')
else:
packet_length = 24 + ((length - 24) // 16) * 16
if not 24 <= packet_length <= length or length - packet_length > 15:
raise ValueError('invalid padded native packet')
body = body[:packet_length]
if len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid native packet alignment or size')
return body, quick
def _encode_reply(body: bytes, tag: bytes) -> bytes:
if not body or len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid HTTP MTProto response length: %d' % len(body))
if tag == PROTO_TAG_ABRIDGED:
words = len(body) // 4
prefix = bytes([words]) if words < 0x7f else b'\x7f' + words.to_bytes(3, 'little')
return prefix + body
padding = os.urandom(os.urandom(1)[0] % 4) if tag == PROTO_TAG_SECURE else b''
return struct.pack('<I', len(body) + len(padding)) + body + padding
class _HttpChannel:
def __init__(self, lane, channel_id: int, label: str):
self.lane, self.channel_id, self.label = lane, channel_id, label
self.closed = False
self.transport_error: Optional[int] = None
self.pending: Dict[asyncio.Task, int] = {}
self.pending_since: Dict[asyncio.Task, float] = {}
self.sent_since: Dict[asyncio.Task, float] = {}
self.pending_bytes = 0
self.reply_bytes = 0
self.queue = asyncio.Queue(maxsize=16)
self.capacity = asyncio.Condition()
self.recovery_wakeup = asyncio.Event()
self.requests = self.up = self.down = self.quick_requests = 0
self.started = time.monotonic()
self.receiving_tasks: Set[asyncio.Task] = set()
self.delivering_since: Optional[float] = None
self.replay_pending: Set[asyncio.Task] = set()
self.replay_history: List[_ReplayPacket] = []
self.replay_key: Optional[bytes] = None
self.replay_requests = 0
self.poll_requests = 0
self.first_poll_gap = 0.0
self.replay_rotations = 0
self.capacity_rotations = 0
self.last_progress = self.started
self.last_native_send = self.started
self.close_lock = asyncio.Lock()
self.send_lock = asyncio.Lock()
self.close_done = False
self.request_traces: Dict[asyncio.Task, _RequestTrace] = {}
self.recent_traces = deque(maxlen=16)
self.upload_wait_since: Optional[float] = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
self.http_bytes = self.small_responses = self.large_responses = 0
self.flow_sample = (self.started, 0, 0, 0, 0, 0, 0, 0)
lane.channels.add(self)
@property
def receiving_http(self) -> int:
return len(self.receiving_tasks)
@property
def native_backpressure(self) -> bool:
return bool(self.reply_bytes) or self.delivering_since is not None
def _has_capacity(self, length: int) -> bool:
return len(self.pending) < MAX_CHANNEL_REQUESTS and self.pending_bytes + length <= MAX_CHANNEL_BYTES
def _wake_receiver(self) -> None:
if not self.closed and not self.pending and not self.native_backpressure:
self.recovery_wakeup.set()
def _overdue_original(self, entry: _ReplayPacket, now: float) -> bool:
return entry.retired or any(task in self.sent_since and task not in self.receiving_tasks
and now - self.sent_since[task] >= REPLAY_REQUEST_SECONDS
for task in entry.originals)
def _replay_limit(self, entry: _ReplayPacket) -> int:
return REPLAY_MAX_ATTEMPTS if entry.originals or entry.retired or entry is self.replay_history[-1] else 0
def _capacity_candidate(self, now: float):
for task, sent in sorted(self.sent_since.items(), key=lambda pair: (
pair[0] not in self.replay_pending, pair[1])):
if (task.done() or task in self.receiving_tasks
or now - sent < REPLAY_REQUEST_SECONDS):
continue
if task in self.replay_pending:
return task, None
entry = next((entry for entry in self.replay_history if task in entry.originals), None)
if entry is not None and now - entry.sent_at <= REPLAY_MAX_AGE:
return task, entry
return None
async def _make_upload_room(self, now: float) -> None:
if self.closed or self.upload_wait_since is None:
return
if self.upload_wait_phase == 'channel-capacity':
if self._has_capacity(self.upload_wait_bytes):
return
channels = [self]
elif self.upload_wait_phase == 'lane-capacity':
if self.lane._has_capacity(self.upload_wait_bytes):
return
channels = sorted(self.lane.channels, key=lambda channel: min(
channel.sent_since.values(), default=now))
else:
return
for channel in channels:
candidate = channel._capacity_candidate(now)
if candidate is None:
continue
task, entry = candidate
channel.sent_since.pop(task, None)
if entry is not None:
entry.retired = True
trace = channel.request_traces.get(task)
if trace is not None:
trace.phase = 'capacity-' + trace.phase
if now >= self.lane.next_capacity_log:
self.lane.next_capacity_log = now + DIAGNOSTIC_INTERVAL
log.debug('H2 CAPACITY lane=%d waiting_channel=%d release_channel=%d '
'reason=%s replay=%d wait_ms=%.0f inflight=%d',
self.lane.lane_id, self.channel_id, channel.channel_id,
self.upload_wait_phase, task in channel.replay_pending,
(now - self.upload_wait_since) * 1000, self.lane.inflight)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
channel.capacity_rotations += 1
self.lane.capacity_rotations += 1
return
def _replay_blocker(self, entry: _ReplayPacket, now: float) -> Optional[str]:
if not 0 <= now - entry.sent_at <= REPLAY_MAX_AGE:
return 'expired'
if entry.attempts >= self._replay_limit(entry):
return 'budget-used'
if self.native_backpressure:
return 'native-backpressure'
if entry.originals.intersection(self.receiving_tasks):
return 'original-body'
if entry.pending in self.receiving_tasks:
return 'replay-body'
if entry.replayed and now - entry.last_attempt_at < REPLAY_RETRY_SECONDS:
return 'backoff-or-progress'
if self._overdue_original(entry, now):
return None
if (entry is not self.replay_history[-1]
or any(task is not entry.pending for task in self.pending)):
return 'receiver-active'
if entry.replayed and now - self.last_progress < REPLAY_IDLE_SECONDS:
return 'backoff-or-progress'
return None
def _replay_due(self, entry: _ReplayPacket, now: float) -> bool:
return self._replay_blocker(entry, now) is None
def _wait_state(self, now: float) -> str:
oldest = min(self.pending_since.values(), default=now)
recovery_left = sum(max(0, self._replay_limit(entry) - entry.attempts)
for entry in self.replay_history if now - entry.sent_at <= REPLAY_MAX_AGE)
waiting_headers = sum(task not in self.replay_pending and task not in self.receiving_tasks
and now - since >= REPLAY_REQUEST_SECONDS
for task, since in self.pending_since.items())
return ('channel=%d pending=%d replays=%d receiving=%d ready_bytes=%d '
'native_write=%d idle_ms=%.0f oldest_ms=%.0f native_age_ms=%.0f '
'recovery_left=%d waiting_headers=%d' % (
self.channel_id, len(self.pending), len(self.replay_pending),
self.receiving_http, self.reply_bytes, self.delivering_since is not None,
(now - self.last_progress) * 1000, (now - oldest) * 1000,
(now - self.last_native_send) * 1000, recovery_left, waiting_headers))
def _request_detail(self, trace: _RequestTrace, now: float) -> str:
entry = next((entry for entry in self.replay_history if entry.sequence == trace.packet), None)
recovery = 'not-retained'
attempts = '-'
if entry is not None:
attempts = '%d/%d' % (entry.attempts, self._replay_limit(entry))
blocker = self._replay_blocker(entry, now)
if blocker is not None:
recovery = blocker
elif entry.pending is not None:
recovery = ('replay-body' if entry.pending in self.receiving_tasks else
'probe-%.0fms' % ((now - self.pending_since.get(entry.pending, now)) * 1000))
elif len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
recovery = 'replay-slots'
elif not self._has_capacity(len(entry.body)):
recovery = 'channel-capacity'
elif not self.lane._has_capacity(len(entry.body)):
recovery = 'lane-capacity'
elif self.send_lock.locked():
recovery = 'send-lock'
else:
recovery = 'eligible'
related = [item for item in self.recent_traces if item.packet == trace.packet][-2:]
return ('channel=%d %s attempts=%s recovery=%s recent=[%s]' % (
self.channel_id, trace.summary(now), attempts, recovery,
'; '.join(item.summary(now) for item in related)))
def _flow_state(self, now: float) -> str:
previous = self.flow_sample
current = (now, self.requests, self.replay_requests, self.http_bytes,
self.down, self.small_responses, self.large_responses, self.poll_requests)
self.flow_sample = current
poll_gap, self.first_poll_gap = self.first_poll_gap, 0.0
probes = [trace.summary(now) for trace in self.request_traces.values() if trace.replay][:2]
return ('channel=%d window_ms=%.0f requests=%d replays=%d http_down=%d native_down=%d '
'small=%d large=%d pending=%d ready=%d upload_wait=%s/%.0fms native_write_ms=%.0f '
'capacity_rotations=%d polls=%d first_poll_ms=%.0f probes=[%s]' % (
self.channel_id, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 7)),
len(self.pending), self.reply_bytes, self.upload_wait_phase,
0 if self.upload_wait_since is None else (now - self.upload_wait_since) * 1000,
0 if self.delivering_since is None else (now - self.delivering_since) * 1000,
self.capacity_rotations,
current[7] - previous[7], poll_gap * 1000,
'; '.join(probes)))
def _fail(self, error: Exception) -> None:
if self.closed:
return
if isinstance(error, _MTProtoTransportError):
self.transport_error = error.code
self.closed = True
self.recovery_wakeup.set()
while not self.queue.empty():
self.queue.get_nowait()
self.reply_bytes = 0
self.queue.put_nowait(error)
async def send(self, body: bytes, quick: bool, replay: bool = False) -> bool:
if replay and self.send_lock.locked():
return False
if not replay:
self.upload_wait_since = time.monotonic()
self.upload_wait_phase = 'send-lock'
self.upload_wait_bytes = len(body)
try:
async with self.send_lock:
return await self._send(body, quick, replay)
finally:
if not replay:
self.upload_wait_since = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
async def _send(self, body: bytes, quick: bool, replay: bool) -> bool:
async with self.capacity:
if replay and not self._has_capacity(len(body)):
return False
if not replay:
self.upload_wait_phase = 'channel-capacity'
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(len(body))),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 channel closed')
if not replay:
self.upload_wait_phase = 'lane-capacity'
if not await self.lane._reserve(len(body), wait=not replay):
return False
if self.closed:
await self.lane._release(len(body))
raise ConnectionError('H2 channel closed')
replay_packet = self._find_packet(body) if replay else None
now = time.monotonic()
if replay and (replay_packet is None or replay_packet.pending is not None
or not self._replay_due(replay_packet, now)):
await self.lane._release(len(body))
return False
self.pending_bytes += len(body)
self.requests += 1
self.replay_requests += int(replay)
self.up += len(body)
stats.bytes_up += len(body)
if not replay:
self.last_native_send = now
native_packet = self._remember_packet(body, now) if not replay else None
if quick:
self.quick_requests += 1
packet = replay_packet or native_packet
trace = (_RequestTrace(packet.sequence if packet is not None else self.requests,
now, replay, len(body))
if log.isEnabledFor(logging.DEBUG) else None)
task = asyncio.create_task(self._run_request(body, replay, packet, trace))
self.pending[task] = len(body)
self.pending_since[task] = now
if trace is not None:
self.request_traces[task] = trace
if native_packet is not None:
native_packet.originals.add(task)
self._trim_history(now)
if replay:
if not self._overdue_original(replay_packet, now):
self.poll_requests += 1
if not replay_packet.replayed:
self.first_poll_gap = max(self.first_poll_gap, now - self.last_progress)
self.replay_pending.add(task)
replay_packet.attempts += 1
replay_packet.last_attempt_at = now
replay_packet.pending = task
return True
def _find_packet(self, body: bytes) -> Optional[_ReplayPacket]:
return next((entry for entry in self.replay_history if entry.body == body), None)
def _remember_packet(self, body: bytes, now: float) -> Optional[_ReplayPacket]:
key = body[:8]
if key == b'\x00' * 8:
return None
if self.replay_key != key:
self.replay_history.clear()
self.replay_key = key
if len(body) > REPLAY_HISTORY_BYTES:
return None
packet = self._find_packet(body)
if packet is None:
packet = _ReplayPacket(now, body, self.requests)
self.replay_history.append(packet)
return packet
def _trim_history(self, now: float) -> None:
retained_bytes = sum(len(entry.body) for entry in self.replay_history)
while len(self.replay_history) > REPLAY_HISTORY_PACKETS or retained_bytes > REPLAY_HISTORY_BYTES:
victim = next((entry for entry in self.replay_history if not entry.originals
and not (entry.retired and entry.attempts < REPLAY_MAX_ATTEMPTS
and now - entry.sent_at <= REPLAY_MAX_AGE)),
self.replay_history[0])
self.replay_history.remove(victim)
retained_bytes -= len(victim.body)
async def _run_request(self, body: bytes, replay: bool, packet: Optional[_ReplayPacket],
trace: Optional[_RequestTrace]) -> None:
task = asyncio.current_task()
try:
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
self.last_progress = time.monotonic()
if reply and not self.closed:
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
raise BufferError('H2 client response queue exceeded bound')
self.reply_bytes += len(reply)
self.queue.put_nowait(reply)
if trace is not None:
trace.phase = 'complete'
except asyncio.CancelledError:
if trace is not None:
trace.phase = 'cancelled-' + trace.phase
raise
except _MTProtoTransportError as exc:
if trace is not None:
trace.phase = 'transport-error'
stats.h2_errors += 1
if not self.closed:
log.warning('[%s] H2 lane=%d channel=%d host=%s MTProto transport error=%s '
'request=%d encrypted=%d bytes=%d replay=%d; reporting to client',
self.label, self.lane.lane_id, self.channel_id, self.lane.host, exc,
trace.request if trace is not None else 0,
body[:8] != b'\x00' * 8, len(body), replay)
self._fail(exc)
except Exception as exc:
if trace is not None:
trace.phase = 'error-' + type(exc).__name__
stats.h2_errors += 1
if isinstance(exc, (httpx.HTTPError, OSError, ValueError, BufferError)):
log.warning('[%s] H2 lane=%d channel=%d request failed: %s: %s; '
'detail=[%s]; '
'closing native channel, reconnect may select another domain',
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc,
trace.summary(time.monotonic()) if trace is not None else '-')
else:
log.exception('[%s] H2 lane=%d channel=%d unexpected request failure',
self.label, self.lane.lane_id, self.channel_id)
self._fail(exc)
finally:
await self._finish_request(task, packet)
async def _finish_request(self, task: asyncio.Task, packet: Optional[_ReplayPacket] = None) -> None:
length = self.pending.pop(task, None)
if length is None:
return
trace = self.request_traces.pop(task, None)
if trace is not None:
trace.finished_at = time.monotonic()
self.recent_traces.append(trace)
self.pending_bytes -= length
self.pending_since.pop(task, None)
self.sent_since.pop(task, None)
self.replay_pending.discard(task)
if packet is None:
packet = next((entry for entry in self.replay_history
if task in entry.originals or entry.pending is task), None)
if packet is not None:
packet.originals.discard(task)
if packet.pending is task:
packet.pending = None
await self.lane._release(length)
async with self.capacity:
self.capacity.notify_all()
self._wake_receiver()
async def _recover(self) -> None:
while not self.closed:
try:
await asyncio.wait_for(self.recovery_wakeup.wait(), REPLAY_CHECK_SECONDS)
except asyncio.TimeoutError:
pass
self.recovery_wakeup.clear()
now = time.monotonic()
self.lane.log_wait(now)
await self._make_upload_room(now)
if self.closed or self.native_backpressure:
continue
def priority(entry):
overdue = self._overdue_original(entry, now)
return (not overdue, entry.sequence if overdue else -entry.sequence)
candidates = sorted(self.replay_history, key=priority)
for entry in candidates:
if not self._replay_due(entry, now):
continue
replace = entry.pending
if replace is None and len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
replace = next((task for task in self.pending if task in self.replay_pending
and task not in self.receiving_tasks), None)
if replace is None:
continue
if replace is not None:
sent = self.sent_since.get(replace)
if (replace in self.receiving_tasks
or sent is None or now - sent < REPLAY_SLOT_SECONDS):
continue
replace.cancel()
await asyncio.gather(replace, return_exceptions=True)
self.replay_rotations += 1
await self.send(entry.body, False, replay=True)
break
async def receive(self) -> bytes:
value = await self.queue.get()
if isinstance(value, Exception):
raise value
self.reply_bytes -= len(value)
self._wake_receiver()
return value
async def close(self) -> None:
async with self.close_lock:
if self.close_done:
return
waiting = self._wait_state(time.monotonic())
if log.isEnabledFor(logging.DEBUG) and self.request_traces:
now = time.monotonic()
traces = sorted(self.request_traces.values(), key=lambda trace: trace.started)[:3]
log.debug('H2 DETAIL CLOSE lane=%d [%s]', self.lane.lane_id,
'; '.join(self._request_detail(trace, now) for trace in traces))
self._fail(ConnectionError('H2 channel closed'))
self.closed = True
tasks = list(self.pending)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
for task in list(self.pending):
await self._finish_request(task)
self.replay_pending.clear()
self.replay_history.clear()
self.sent_since.clear()
self.request_traces.clear()
self.recent_traces.clear()
self.lane.channels.discard(self)
self.close_done = True
log.debug('[%s] H2 channel=%d closed lane=%d requests=%d replays=%d rotations=%d capacity_rotations=%d up=%d down=%d '
'quickack=%d duration=%.1fs remaining_channels=%d; %s',
self.label, self.channel_id, self.lane.lane_id, self.requests,
self.replay_requests, self.replay_rotations, self.capacity_rotations, self.up, self.down, self.quick_requests,
time.monotonic() - self.started, len(self.lane.channels), waiting)
class _HttpLane:
def __init__(self, host: str, lane_id: int):
self.host, self.lane_id = host, lane_id
self.channels: Set[_HttpChannel] = set()
self.failed_until = 0.0
self.closed = False
self.tcp_connections = self.requests = self.responses = self.errors = 0
self.slow_headers = self.slow_bodies = self.replays = 0
self.max_headers_seconds = self.max_body_seconds = 0.0
self.next_wait_log = self.next_slow_log = 0.0
self.next_capacity_log = 0.0
self.capacity_rotations = 0
self.next_large_slow_log = 0.0
self.inflight = self.queued_bytes = self.max_inflight = 0
self.reply_buffer_bytes = 0
self.capacity = asyncio.Condition()
self.client = httpx.AsyncClient(
transport=H2Transport(create_ssl_context(), max_streams=MAX_LANE_REQUESTS),
trust_env=False,
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
)
async def _trace(self, event: str, info: Dict) -> None:
if event == 'connection.connect_tcp.started':
self.tcp_connections += 1
stats.h2_tcp_connections += 1
log.debug('H2 lane=%d TCP_OPEN connection=%d host=%s channels=%d',
self.lane_id, self.tcp_connections, self.host, len(self.channels))
async def _preflight(self) -> None:
started = time.monotonic()
async with self.client.stream('HEAD', 'https://' + self.host + '/api',
extensions={'trace': self._trace}) as response:
if response.http_version != 'HTTP/2':
raise ValueError('CF /api preflight negotiated ' + response.http_version)
if not (200 <= response.status_code < 300 or response.status_code in (405, 501)):
raise ConnectionError('CF /api preflight HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
log.debug('H2 lane=%d ready host=%s status=%d ms=%.0f',
self.lane_id, self.host, response.status_code,
(time.monotonic() - started) * 1000)
def _has_capacity(self, length: int) -> bool:
return self.inflight < MAX_LANE_REQUESTS and self.queued_bytes + length <= MAX_LANE_BYTES
async def _reserve(self, length: int, wait: bool = True) -> bool:
async with self.capacity:
if not wait and not self._has_capacity(length):
return False
if wait:
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(length)),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 lane closed')
self.inflight += 1
self.queued_bytes += length
self.max_inflight = max(self.max_inflight, self.inflight)
return True
async def _release(self, length: int) -> None:
async with self.capacity:
self.inflight -= 1
self.queued_bytes -= length
self.capacity.notify_all()
async def _post(self, body: bytes, channel_id: int, replay: bool = False,
channel: Optional[_HttpChannel] = None) -> bytes:
self.requests += 1
self.replays += int(replay)
stats.h2_requests += 1
request_id = self.requests
started = time.monotonic()
buffered = 0
receiving = False
request_task = asyncio.current_task()
trace = channel.request_traces.get(request_task) if channel is not None else None
if trace is not None:
trace.request = request_id
trace.phase = 'connect-or-slot'
async def request_trace(event, info):
await self._trace(event, info)
if event == 'http2.send_request_body.complete' and channel is not None:
channel.sent_since[request_task] = time.monotonic()
if trace is not None:
if event == 'connection.connect_tcp.started':
trace.phase = 'connect'
elif event == 'http2.wait_for_stream.started':
trace.phase = 'stream-slot'
elif event == 'http2.send_request_headers.started':
trace.stream = info['stream_id']
trace.phase = 'upload'
elif event == 'http2.send_request_body.complete':
trace.sent_at = time.monotonic()
trace.phase = 'headers' if trace.headers_at is None else 'body'
def response_started():
nonlocal receiving
if trace is not None and trace.headers_at is None:
trace.headers_at = time.monotonic()
trace.phase = 'body'
if channel is not None and not receiving:
channel.receiving_tasks.add(request_task)
channel.last_progress = time.monotonic()
receiving = True
try:
async with self.client.stream(
'POST', 'https://' + self.host + '/api', content=body,
headers={'content-type': 'application/octet-stream', 'accept-encoding': 'identity'},
extensions={'trace': request_trace,
'h2_response_started': response_started}) as response:
headers_at = time.monotonic()
if trace is not None:
trace.cf_ray = ''.join(c for c in response.headers.get('cf-ray', '-')[:64]
if c.isascii() and (c.isalnum() or c == '-'))
self.slow_headers += int(headers_at - started >= SLOW_RESPONSE_SECONDS)
self.max_headers_seconds = max(self.max_headers_seconds, headers_at - started)
if response.http_version != 'HTTP/2':
raise ValueError('CF /api downgraded to ' + response.http_version)
if response.status_code in (403, 404, 429, 444):
raise _MTProtoTransportError(-response.status_code, 'HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if response.status_code != 200:
raise ConnectionError('CF /api HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if 'text/html' in response.headers.get('content-type', '').lower():
raise ValueError('CF /api returned HTML instead of MTProto')
response_started()
length_header = response.headers.get('content-length')
declared_length = int(length_header) if length_header is not None else None
if declared_length is not None and not 0 <= declared_length <= MAX_PACKET:
raise BufferError('H2 response Content-Length exceeds native packet bound')
content = bytearray()
async for chunk in response.aiter_bytes():
if len(content) + len(chunk) > MAX_PACKET:
raise BufferError('H2 response exceeds native packet bound')
if self.reply_buffer_bytes + len(chunk) > MAX_LANE_REPLY_BYTES:
raise _ReplyBufferFull('H2 lane response buffers exceeded bound')
self.reply_buffer_bytes += len(chunk)
buffered += len(chunk)
content.extend(chunk)
if trace is not None:
trace.received += len(chunk)
if channel is not None:
channel.last_progress = time.monotonic()
received = len(content)
if (declared_length is not None
and response.headers.get('content-encoding', 'identity') == 'identity'
and received != declared_length):
raise ValueError('incomplete H2 response body')
if content and len(content) % 4:
raise ValueError('CF /api response is not an aligned MTProto packet')
if len(content) == 4:
code, = struct.unpack('<i', content)
if code < 0:
raise _MTProtoTransportError(code, 'HTTP 200 payload')
self.responses += 1
if channel is not None:
channel.http_bytes += received
channel.small_responses += int(received <= 4096)
channel.large_responses += int(received > 4096)
finished = time.monotonic()
body_seconds = finished - headers_at
self.max_body_seconds = max(self.max_body_seconds, body_seconds)
self.slow_bodies += int(body_seconds >= SLOW_RESPONSE_SECONDS)
next_slow = self.next_large_slow_log if received > 4096 else self.next_slow_log
if finished - started >= SLOW_RESPONSE_SECONDS and finished >= next_slow:
if received > 4096:
self.next_large_slow_log = finished + DIAGNOSTIC_INTERVAL
else:
self.next_slow_log = finished + DIAGNOSTIC_INTERVAL
log.debug('H2 SLOW lane=%d channel=%d request=%d bytes=%d replay=%d '
'headers_ms=%.0f body_ms=%.0f detail=[%s]',
self.lane_id, channel_id, request_id, received, replay,
(headers_at - started) * 1000, body_seconds * 1000,
trace.summary(finished) if trace is not None else '-')
return bytes(content)
except _MTProtoTransportError:
self.errors += 1
raise
except _ReplyBufferFull:
raise
except (httpx.HTTPError, OSError, ValueError, BufferError):
self.errors += 1
self.failed_until = time.monotonic() + DOMAIN_COOLDOWN
raise
finally:
self.reply_buffer_bytes -= buffered
if receiving:
channel.receiving_tasks.discard(request_task)
def log_wait(self, now: float) -> None:
if not log.isEnabledFor(logging.DEBUG) or now < self.next_wait_log:
return
self.next_wait_log = now + 1.0
waiting = [channel for channel in self.channels
if ((channel.delivering_since is not None
and now - channel.delivering_since >= SLOW_RESPONSE_SECONDS) or any(
task not in channel.replay_pending
and task not in channel.receiving_tasks
and now - started >= SLOW_RESPONSE_SECONDS
for task, started in channel.pending_since.items()))
and now - channel.last_native_send <= REPLAY_MAX_AGE]
if waiting:
self.next_wait_log = now + DIAGNOSTIC_INTERVAL
waiting.sort(key=lambda channel: channel.channel_id)
log.debug('H2 WAIT lane=%d affected=%d inflight=%d body_buffer=%d [%s]',
self.lane_id, len(waiting), self.inflight, self.reply_buffer_bytes,
'; '.join(channel._wait_state(now) for channel in waiting[:8]))
details = sorted(((channel, trace) for channel in waiting for trace in channel.request_traces.values()
if not trace.replay and now - trace.started >= SLOW_RESPONSE_SECONDS),
key=lambda pair: pair[1].started)[:12]
if details:
log.debug('H2 DETAIL lane=%d [%s]', self.lane_id,
'; '.join(channel._request_detail(trace, now) for channel, trace in details))
def log_flow(self, now: float) -> None:
if log.isEnabledFor(logging.DEBUG) and self.channels:
channels = sorted(self.channels, key=lambda channel: channel.channel_id)
for offset in range(0, len(channels), 8):
log.debug('H2 FLOW lane=%d [%s]', self.lane_id,
'; '.join(channel._flow_state(now) for channel in channels[offset:offset + 8]))
def summary(self) -> str:
return ('lane=%d host=%s channels=%d tcp_connections=%d requests=%d replies=%d '
'errors=%d inflight=%d max_inflight=%d queued_bytes=%d reply_buffer_bytes=%d '
'replays=%d capacity_rotations=%d slow_headers=%d slow_bodies=%d max_headers_ms=%.0f max_body_ms=%.0f' % (
self.lane_id, self.host, len(self.channels), self.tcp_connections,
self.requests, self.responses, self.errors, self.inflight,
self.max_inflight, self.queued_bytes, self.reply_buffer_bytes,
self.replays, self.capacity_rotations, self.slow_headers, self.slow_bodies,
self.max_headers_seconds * 1000, self.max_body_seconds * 1000))
async def close(self) -> None:
self.closed = True
async with self.capacity:
self.capacity.notify_all()
await asyncio.gather(*(channel.close() for channel in list(self.channels)))
await self.client.aclose()
log.debug('H2 CLOSED %s', self.summary())
class CfH2Pool:
def __init__(self):
for name in ('httpx', 'httpcore', 'hpack', 'h2'):
logging.getLogger(name).setLevel(logging.WARNING)
self.lanes: Dict[str, _HttpLane] = {}
self.locks: Dict[str, asyncio.Lock] = {}
self.failed_until: Dict[str, float] = {}
self.setup_retry_after: Dict[int, float] = {}
self.next_lane = self.next_channel = 0
self.closed = False
async def open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if time.monotonic() < self.setup_retry_after.get(dc, 0):
return None
try:
return await asyncio.wait_for(self._open(dc, label), SETUP_TIMEOUT)
except asyncio.TimeoutError:
self.setup_retry_after[dc] = time.monotonic() + DOMAIN_COOLDOWN
stats.h2_errors += 1
log.warning('[%s] H2 DC%d setup timed out; using WS for %.0fs',
label, dc, DOMAIN_COOLDOWN)
return None
async def _open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if dc not in DC_DEFAULT_IPS:
log.warning('[%s] H2 unsupported DC%d -> existing WS route', label, dc)
return None
for base_domain in balancer.get_domains_for_dc(dc):
host = 'kws%d.%s' % (dc, base_domain)
async with self.locks.setdefault(host, asyncio.Lock()):
if self.closed:
raise ConnectionError('H2 pool closed')
lane = self.lanes.get(host)
if time.monotonic() < max(self.failed_until.get(host, 0),
lane.failed_until if lane else 0):
continue
if lane is None:
self.next_lane += 1
lane = _HttpLane(host, self.next_lane)
try:
await lane._preflight()
except (httpx.HTTPError, OSError, ValueError) as exc:
stats.h2_errors += 1
self.failed_until[host] = time.monotonic() + DOMAIN_COOLDOWN
log.warning('[%s] H2 lane=%d preflight failed host=%s: %s: %s',
label, lane.lane_id, host, type(exc).__name__, exc)
await lane.close()
continue
except asyncio.CancelledError:
await lane.close()
raise
if self.closed:
await lane.close()
raise ConnectionError('H2 pool closed during preflight')
self.lanes[host] = lane
self.next_channel += 1
channel = _HttpChannel(lane, self.next_channel, label)
balancer.update_domain_for_dc(dc, base_domain)
log.info('[%s] ROUTE DC%d media -> H2 lane=%d channel=%d host=%s '
'channels=%d tcp_connections=%d', label, dc, lane.lane_id,
channel.channel_id, host, len(lane.channels), lane.tcp_connections)
return channel
log.debug('[%s] H2 DC%d unavailable; using WS', label, dc)
return None
def log_stats(self) -> None:
for lane in self.lanes.values():
if lane.channels:
log.debug('H2 STATS %s', lane.summary())
def log_flow(self, now: float) -> None:
for lane in self.lanes.values():
lane.log_flow(now)
async def close(self) -> None:
self.closed = True
await asyncio.gather(*(lane.close() for lane in self.lanes.values()))
self.lanes.clear()
async def bridge_h2(reader, writer, channel: _HttpChannel, ctx, tag: bytes) -> None:
async def write_native(data):
try:
writer.write(ctx.clt_enc.update(data))
await writer.drain()
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
async def upload() -> None:
while True:
try:
body, quick = await _read_packet(reader, ctx.clt_dec, tag)
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
await channel.send(body, quick)
async def download() -> None:
while True:
body = await channel.receive()
if channel.closed:
raise _NativeDeliveryError('H2 channel closed before native response')
channel.delivering_since = time.monotonic()
try:
await write_native(_encode_reply(body, tag))
finally:
channel.delivering_since = None
channel.down += len(body)
stats.bytes_down += len(body)
channel.last_progress = time.monotonic()
channel._wake_receiver()
tasks = [asyncio.create_task(upload()), asyncio.create_task(download()),
asyncio.create_task(channel._recover())]
try:
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if channel.transport_error is not None:
await write_native(_encode_reply(struct.pack('<i', channel.transport_error), tag))
channel.down += 4
stats.bytes_down += 4
else:
for task in done:
task.result()
except asyncio.IncompleteReadError:
log.debug('[%s] H2 channel=%d native EOF', channel.label, channel.channel_id)
except _NativeDeliveryError as exc:
log.debug('[%s] H2 channel=%d native closed: %s', channel.label, channel.channel_id, exc)
except (httpx.HTTPError, OSError, ValueError, BufferError, asyncio.TimeoutError) as exc:
if not channel.closed:
log.warning('[%s] H2 channel=%d bridge ended: %s: %s',
channel.label, channel.channel_id, type(exc).__name__, exc)
finally:
await channel.close()
+25 -6
View File
@@ -29,12 +29,17 @@ _CFPROXY_ENC: List[str] = [
'clngqrflngqin.com', 'clngqrflngqin.com',
'tjacxbqtj.com', 'tjacxbqtj.com',
'bxaxtxmrw.com', 'bxaxtxmrw.com',
'dmohrsgmohcrwb.com' 'dmohrsgmohcrwb.com',
'vwbmtmoi.com', 'vwbmtmoi.com',
'khgrre.com', 'khgrre.com',
'ulihssf.com', 'ulihssf.com',
'tmhqsdqmfpmk.com', 'tmhqsdqmfpmk.com',
'xwuwoqbm.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))
@@ -65,8 +70,16 @@ class ProxyConfig:
fallback_cfproxy: bool = True fallback_cfproxy: bool = True
cfproxy_user_domains: List[str] = field(default_factory=list) cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domains: List[str] = field(default_factory=list) cfproxy_worker_domains: List[str] = field(default_factory=list)
cfproxy_h2_media: bool = True
disable_secure: bool = False
fake_tls_domain: str = '' fake_tls_domain: str = ''
proxy_protocol: bool = False proxy_protocol: bool = False
force_test_dc: bool = False
@property
def h2_enabled(self) -> bool:
return (self.cfproxy_h2_media and self.fallback_cfproxy
and not self.disable_secure and not self.force_test_dc)
proxy_config = ProxyConfig() proxy_config = ProxyConfig()
@@ -195,13 +208,19 @@ 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_pton(_socket.AF_INET, 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 from None
dc_redirects[dc_n] = ip_s dc_redirects[dc_n] = ip_s
return dc_redirects return dc_redirects
+332
View File
@@ -0,0 +1,332 @@
from __future__ import annotations
import asyncio
import time
from collections import deque
import httpx
from h2.config import H2Configuration
from h2.connection import H2Connection
from h2.errors import ErrorCodes
from h2.events import (
ConnectionTerminated, DataReceived, RemoteSettingsChanged,
ResponseReceived, StreamEnded, StreamReset, WindowUpdated,
)
from h2.exceptions import H2Error, StreamClosedError
from h2.settings import SettingCodes, Settings
STREAM_RECEIVE_WINDOW = 256 * 1024
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
class _ResponseStream(httpx.AsyncByteStream):
def __init__(self, connection, stream_id, read_timeout):
self.connection = connection
self.stream_id = stream_id
self.read_timeout = read_timeout
self.changed = asyncio.Event()
self.chunks = deque()
self.headers = None
self.response_started = None
self.error = None
self.ended = False
self.closed = False
self.extensions = {'http_version': b'HTTP/2', 'stream_id': stream_id}
async def wait(self):
try:
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
except asyncio.TimeoutError as exc:
raise httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id) from exc
def check_error(self):
if self.error is not None:
raise self.error
async def __aiter__(self):
while not self.closed:
self.changed.clear()
self.check_error()
if self.chunks:
data, credit = self.chunks.popleft()
yield data
self.connection.consumed(self, credit)
elif self.ended:
return
else:
await self.wait()
async def aclose(self):
if not self.closed:
self.closed = True
self.chunks.clear()
self.connection.release(self)
self.changed.set()
class _Connection:
def __init__(self, reader, writer, max_streams, write_timeout):
self.reader, self.writer = reader, writer
self.max_streams = max_streams
self.write_timeout = write_timeout
self.h2 = H2Connection(config=H2Configuration(client_side=True))
self.h2.local_settings = Settings(
client=True, initial_values={SettingCodes.ENABLE_PUSH: 0,
SettingCodes.INITIAL_WINDOW_SIZE: STREAM_RECEIVE_WINDOW})
self.streams = {}
self.changed = asyncio.Event()
self.write_lock = asyncio.Lock()
self.settings_received = False
self.error = None
self.h2.initiate_connection()
self.h2.increment_flow_control_window(CONNECTION_RECEIVE_WINDOW - 65535)
self.flush()
self.reader_task = asyncio.create_task(self.read_loop())
def get_extra_info(self, name):
return self.writer.get_extra_info(name)
def flush(self):
data = self.h2.data_to_send()
if data:
self.writer.write(data)
async def drain(self):
try:
async with self.write_lock:
await asyncio.wait_for(self.writer.drain(), self.write_timeout)
except asyncio.TimeoutError as exc:
error = httpx.WriteTimeout('H2 connection write timed out')
self.fail(error)
raise error from exc
except OSError as exc:
error = httpx.WriteError(str(exc))
self.fail(error)
raise error from exc
def fail(self, error):
if self.error is None:
self.error = error
for stream in self.streams.values():
if not stream.ended:
stream.error = error
stream.changed.set()
self.changed.set()
self.writer.close()
async def read_loop(self):
try:
while self.error is None:
data = await self.reader.read(64 * 1024)
if not data:
raise httpx.ReadError('H2 peer closed the connection')
events = self.h2.receive_data(data)
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
for event in events:
if isinstance(event, RemoteSettingsChanged):
self.settings_received = True
self.changed.set()
elif isinstance(event, WindowUpdated):
self.changed.set()
elif isinstance(event, ConnectionTerminated):
raise httpx.RemoteProtocolError('H2 GOAWAY error=%s last_stream=%s' % (
event.error_code, event.last_stream_id))
else:
stream = self.streams.get(getattr(event, 'stream_id', None))
if isinstance(event, DataReceived):
if event.flow_controlled_length and not terminated:
self.h2.increment_flow_control_window(event.flow_controlled_length)
if stream is not None:
if event.data:
stream.chunks.append((event.data, event.flow_controlled_length))
elif not terminated:
self.consumed(stream, event.flow_controlled_length)
elif stream is not None:
if isinstance(event, ResponseReceived):
stream.headers = event.headers
if stream.response_started is not None:
stream.response_started()
elif isinstance(event, StreamEnded):
stream.ended = True
elif isinstance(event, StreamReset):
stream.error = httpx.RemoteProtocolError(
'H2 stream %d reset: %s' % (event.stream_id, event.error_code))
self.changed.set()
if stream is not None:
stream.changed.set()
self.flush()
await self.drain()
except asyncio.CancelledError:
self.fail(httpx.ReadError('H2 reader stopped'))
raise
except (OSError, H2Error, httpx.HTTPError) as exc:
error = exc if isinstance(exc, httpx.HTTPError) else httpx.RemoteProtocolError(str(exc))
self.fail(error)
def check_error(self):
if self.error is not None:
raise self.error
async def reserve(self, timeouts):
timeout = timeouts.get('pool')
deadline = None if timeout is None else time.monotonic() + timeout
while True:
self.changed.clear()
self.check_error()
if self.settings_received and len(self.streams) < min(
self.max_streams, self.h2.remote_settings.max_concurrent_streams):
stream_id = self.h2.get_next_available_stream_id()
stream = _ResponseStream(self, stream_id, timeouts.get('read'))
self.streams[stream_id] = stream
return stream
remaining = None if deadline is None else max(0, deadline - time.monotonic())
try:
await asyncio.wait_for(self.changed.wait(), remaining)
except asyncio.TimeoutError as exc:
raise httpx.PoolTimeout('H2 stream capacity exhausted') from exc
async def send_body(self, request, stream):
async for data in request.stream:
offset = 0
while offset < len(data):
self.changed.clear()
self.check_error()
stream.check_error()
count = min(len(data) - offset, self.h2.max_outbound_frame_size,
self.h2.local_flow_control_window(stream.stream_id))
if count <= 0:
try:
await asyncio.wait_for(self.changed.wait(), self.write_timeout)
except asyncio.TimeoutError as exc:
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
continue
self.h2.send_data(stream.stream_id, data[offset:offset + count])
offset += count
self.flush()
await self.drain()
self.h2.end_stream(stream.stream_id)
self.flush()
await self.drain()
async def request(self, request):
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('http2.wait_for_stream.started', {})
stream = await self.reserve(timeouts)
stream.response_started = request.extensions.get('h2_response_started')
stream_id = stream.stream_id
try:
headers = [(b':method', request.method.encode('ascii')),
(b':scheme', request.url.raw_scheme),
(b':authority', request.headers['host'].encode('ascii')),
(b':path', request.url.raw_path)]
headers.extend((k.lower(), v) for k, v in request.headers.raw if k.lower() not in (
b'host', b'connection', b'keep-alive', b'proxy-connection',
b'upgrade', b'transfer-encoding'))
self.h2.send_headers(stream_id, headers)
self.flush()
if trace:
await trace('http2.send_request_headers.started', {'stream_id': stream_id})
await self.send_body(request, stream)
if trace:
await trace('http2.send_request_body.complete', {'stream_id': stream_id})
while stream.headers is None:
stream.changed.clear()
stream.check_error()
await stream.wait()
stream.check_error()
status = next(int(v) for k, v in stream.headers if k == b':status')
return httpx.Response(status, headers=[(k, v) for k, v in stream.headers
if not k.startswith(b':')],
stream=stream, extensions=stream.extensions)
except H2Error as exc:
await stream.aclose()
raise httpx.LocalProtocolError(str(exc)) from exc
except BaseException:
await stream.aclose()
raise
def consumed(self, stream, credit):
if credit and not stream.ended and not stream.closed and self.error is None:
try:
self.h2.increment_flow_control_window(credit, stream.stream_id)
self.flush()
except StreamClosedError:
pass
def release(self, stream):
self.streams.pop(stream.stream_id, None)
if self.error is None:
try:
self.h2.reset_stream(stream.stream_id, ErrorCodes.CANCEL)
self.flush()
except StreamClosedError:
pass
except OSError as exc:
self.fail(httpx.WriteError(str(exc)))
self.changed.set()
async def aclose(self):
self.fail(httpx.ReadError('H2 connection closed'))
self.reader_task.cancel()
await asyncio.gather(self.reader_task, return_exceptions=True)
try:
await asyncio.wait_for(self.writer.wait_closed(), 2)
except (OSError, asyncio.TimeoutError):
pass
class H2Transport(httpx.AsyncBaseTransport):
def __init__(self, ssl_context, max_streams=64):
self.ssl_context = ssl_context
self.ssl_context.set_alpn_protocols(['h2'])
self.max_streams = max_streams
self.connection = None
self.origin = None
self.connect_lock = asyncio.Lock()
self.closed = False
async def handle_async_request(self, request):
origin = (request.url.host, request.url.port or 443)
if request.url.scheme != 'https' or self.origin not in (None, origin):
raise httpx.UnsupportedProtocol('CF H2 transport requires a single HTTPS origin')
async with self.connect_lock:
if self.closed:
raise httpx.ConnectError('H2 transport closed')
self.origin = origin
if self.connection is None or self.connection.error is not None:
if self.connection is not None:
await self.connection.aclose()
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('connection.connect_tcp.started', {})
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(
*origin, ssl=self.ssl_context, server_hostname=origin[0]),
timeouts.get('connect'))
if writer.get_extra_info('ssl_object').selected_alpn_protocol() != 'h2':
raise httpx.RemoteProtocolError('CF origin did not negotiate h2')
self.connection = _Connection(reader, writer, self.max_streams, timeouts.get('write'))
if trace:
await trace('connection.start_tls.complete', {'return_value': self.connection})
except BaseException as exc:
if self.connection is not None:
self.connection.fail(httpx.ConnectError('H2 connection setup interrupted'))
if writer is not None:
writer.close()
if isinstance(exc, asyncio.TimeoutError):
raise httpx.ConnectTimeout('H2 TLS connect timed out') from exc
if isinstance(exc, OSError):
raise httpx.ConnectError(str(exc)) from exc
raise
connection = self.connection
return await connection.request(request)
async def aclose(self):
self.closed = True
async with self.connect_lock:
if self.connection is not None:
await self.connection.aclose()
+88
View File
@@ -0,0 +1,88 @@
import logging
import time
from collections import defaultdict
log = logging.getLogger('tg-mtproto-proxy')
class WsActivity:
active = set()
def __init__(self, label, dc):
self.label, self.dc = label, dc
self.started = time.monotonic()
self.native_up = self.ws_up = self.ws_down = self.native_down = 0
self.first_input = self.first_reply = self.last_rx = self.last_tx = None
self.awaiting_rx = self.sending = self.writing_native = None
self.max_rx_gap = self.max_send = self.max_native_write = 0.0
self.sample = (self.started, 0, 0, 0, 0)
self.active.add(self)
log.debug('[%s] WS OPEN %s', self.label, self.dc)
def input(self, length):
self.native_up += length
if self.first_input is None:
self.first_input = time.monotonic()
def send_started(self):
self.sending = time.monotonic()
def sent(self, length):
now = time.monotonic()
self.ws_up += length
if self.sending is not None:
self.max_send = max(self.max_send, now - self.sending)
if self.awaiting_rx is None and (self.last_rx is None or self.last_rx < self.sending):
self.awaiting_rx = self.sending
self.last_tx, self.sending = now, None
def received(self, length):
now = time.monotonic()
self.ws_down += length
self.last_rx = now
if self.awaiting_rx is not None:
self.max_rx_gap = max(self.max_rx_gap, now - self.awaiting_rx)
if self.first_reply is None and self.first_input is not None:
self.first_reply = now - self.first_input
self.awaiting_rx = None
self.writing_native = now
def delivered(self, length):
self.native_down += length
if self.writing_native is not None:
self.max_native_write = max(self.max_native_write, time.monotonic() - self.writing_native)
self.writing_native = None
def summary(self, now):
current = (now, self.native_up, self.ws_up, self.ws_down, self.native_down)
previous, self.sample = self.sample, current
return ('client=%s window_ms=%.0f native_up=%d ws_up=%d ws_down=%d native_down=%d '
'rx_gap_ms=%.0f last_rx_ms=%.0f ws_write_ms=%.0f native_write_ms=%.0f' % (
self.label, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 5)),
0 if self.awaiting_rx is None else (now - self.awaiting_rx) * 1000,
-1 if self.last_rx is None else (now - self.last_rx) * 1000,
0 if self.sending is None else (now - self.sending) * 1000,
0 if self.writing_native is None else (now - self.writing_native) * 1000))
def close(self):
now = time.monotonic()
self.active.discard(self)
log.debug('WS END %s %s first_reply_ms=%.0f max_rx_gap_ms=%.0f '
'max_ws_write_ms=%.0f max_native_write_ms=%.0f',
self.dc, self.summary(now),
-1 if self.first_reply is None else self.first_reply * 1000,
self.max_rx_gap * 1000, self.max_send * 1000, self.max_native_write * 1000)
def log_ws_flow(now):
if not log.isEnabledFor(logging.DEBUG):
return
groups = defaultdict(list)
for activity in WsActivity.active:
groups[activity.dc].append(activity)
for dc, activities in sorted(groups.items()):
activities.sort(key=lambda item: item.label)
for offset in range(0, len(activities), 8):
log.debug('WS FLOW %s [%s]', dc,
'; '.join(item.summary(now) for item in activities[offset:offset + 8]))
+233 -79
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import random
import time import time
from collections import deque from collections import deque
@@ -9,21 +10,32 @@ from typing import Dict, List, Optional, Tuple, Set
from .raw_websocket import RawWebSocket, WsHandshakeError from .raw_websocket import RawWebSocket, WsHandshakeError
from .stats import stats from .stats import stats
from .config import proxy_config from .config import proxy_config
from .utils import ws_domains, DC_DEFAULT_IPS from .utils import ws_domains, DC_DEFAULT_IPS, WS_PATH, WS_PATH_TEST
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
class _WsPool: class _WsPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {} self._idle: Dict[Tuple[int, bool, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set() self._refilling: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._rotating: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool, bool], float] = {}
self.try_fronting_first = False
async def get(self, dc: int, is_media: bool, async def get(self, dc: int, is_media: bool, *, is_test_dc: bool = False
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]: ) -> Optional[RawWebSocket]:
key = (dc, is_media) target_ip = proxy_config.dc_redirects.get(dc)
if not target_ip or proxy_config.pool_size <= 0:
return None
key = (dc, is_media, is_test_dc)
domains = ws_domains(dc, is_media)
now = time.monotonic() now = time.monotonic()
bucket = self._idle.get(key) bucket = self._idle.get(key)
@@ -33,13 +45,12 @@ class _WsPool:
while bucket: while bucket:
ws, created = bucket.popleft() ws, created = bucket.popleft()
age = now - created age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed if self._is_stale(ws, created, now):
or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws)) asyncio.create_task(self._quiet_close(ws))
continue continue
stats.pool_hits += 1 stats.pool_hits += 1
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket)) dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains) self._schedule_refill(key, target_ip, domains)
return ws return ws
@@ -47,50 +58,140 @@ class _WsPool:
self._schedule_refill(key, target_ip, domains) self._schedule_refill(key, target_ip, domains)
return None return None
def _is_stale(self, ws, created, now):
return (now - created >= self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing() or ws.reader.at_eof()
or ws.reader.exception() is not None)
def _schedule_refill(self, key, target_ip, domains): def _schedule_refill(self, key, target_ip, domains):
if key in self._refilling: if proxy_config.pool_size <= 0:
return return
self._refilling.add(key) self._schedule_rotation(key, target_ip, domains)
asyncio.create_task(self._refill(key, target_ip, domains)) if (key in self._refilling
or time.monotonic() < self._refill_after.get(key, 0)):
return
self._refilling[key] = asyncio.create_task(
self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains): async def _refill(self, key, target_ip, domains):
dc, is_media = key dc, is_media, is_test_dc = key
tasks = []
adopted = set()
try: try:
bucket = self._idle.setdefault(key, deque()) bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket) needed = proxy_config.pool_size - len(bucket)
if needed <= 0: if needed <= 0:
return return
connected = 0
tasks = [asyncio.create_task( tasks = [asyncio.create_task(
self._connect_one(target_ip, domains)) self._connect_one(target_ip, domains,
WS_PATH_TEST if is_test_dc else WS_PATH))
for _ in range(needed)] for _ in range(needed)]
for t in tasks: for t in asyncio.as_completed(tasks):
try: try:
ws = await t ws = await t
if ws: if ws:
if self._refilling.get(key) is not asyncio.current_task():
return
bucket.append((ws, time.monotonic())) bucket.append((ws, time.monotonic()))
except Exception: adopted.add(ws)
pass connected += 1
log.debug("WS pool refilled DC%d%s: %d ready", except Exception as exc:
dc, 'm' if is_media else '', len(bucket)) log.debug("WS pool connect failed: %r", exc)
if connected:
self._refill_failures.pop(key, None)
self._refill_after.pop(key, None)
else:
failures = self._refill_failures.get(key, 0) + 1
self._refill_failures[key] = failures
delay = min(
self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX,
)
self._refill_after[key] = time.monotonic() + delay
log.info(
"WS pool refill failed for DC%d%s%s, retry in %.0fs",
dc, 't' if is_test_dc else '', 'm' if is_media else '', delay)
log.debug("WS pool refilled DC%d%s%s: %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '', len(bucket))
finally: finally:
self._refilling.discard(key) for task in tasks:
if not task.done():
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for ws in results:
if ws is not None and not isinstance(ws, BaseException) and ws not in adopted:
await self._quiet_close(ws)
if self._refilling.get(key) is asyncio.current_task():
self._refilling.pop(key, None)
@staticmethod def _schedule_rotation(self, key, target_ip, domains):
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]: if key in self._rotating:
return
self._rotating[key] = asyncio.create_task(
self._rotate(key, target_ip, domains))
async def _rotate(self, key, target_ip, domains):
dc, is_media, is_test_dc = key
try:
while proxy_config.pool_size > 0:
bucket = self._idle.setdefault(key, deque())
now = time.monotonic()
expired = []
ready = deque()
while bucket:
ws, created = bucket.popleft()
if self._is_stale(ws, created, now):
expired.append(ws)
else:
ready.append((ws, created))
bucket.extend(ready)
if expired:
for ws in expired:
asyncio.create_task(self._quiet_close(ws))
log.debug(
"WS pool rotated DC%d%s%s: %d stale, %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '',
len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains)
wake_at = now + self.WS_POOL_CHECK_INTERVAL
if bucket:
wake_at = min(wake_at, min(
created + self.WS_POOL_MAX_AGE for _, created in bucket))
if key not in self._refilling and self._refill_after.get(key, 0) > now:
wake_at = min(wake_at, self._refill_after[key])
await asyncio.sleep(max(0, wake_at - time.monotonic()))
finally:
if self._rotating.get(key) is asyncio.current_task():
self._rotating.pop(key, None)
async def _connect_one(self, target_ip, domains, path=WS_PATH) -> Optional[RawWebSocket]:
for domain in domains: for domain in domains:
try: modes = (True, False) if self.try_fronting_first else (False, True)
return await RawWebSocket.connect( for fronted in modes:
target_ip, domain, timeout=8) try:
except WsHandshakeError as exc: ws = await RawWebSocket.connect(
if exc.is_redirect: target_ip, domain, timeout=7 if fronted else 8, path=path,
sni="sprinthost.ru" if fronted else None)
except Exception as exc:
stats.ws_errors += 1
log.debug("WS pool connect %s%s via %s (fronting=%s): %r",
domain, path, target_ip, fronted, exc)
continue continue
return None self.try_fronting_first = fronted
except Exception: if fronted:
return None stats.connections_fronting += 1
log.debug("WS pool connected %s%s via %s (fronting=%s)",
domain, path, target_ip, fronted)
return ws
return None return None
@staticmethod async def _quiet_close(self, ws):
async def _quiet_close(ws):
try: try:
await ws.close() await ws.close()
except Exception: except Exception:
@@ -102,89 +203,141 @@ class _WsPool:
continue continue
for is_media in (False, True): for is_media in (False, True):
domains = ws_domains(dc, is_media) domains = ws_domains(dc, is_media)
self._schedule_refill((dc, is_media), target_ip, domains) key = (dc, is_media, proxy_config.force_test_dc)
self._schedule_refill(key, target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects)) log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
def reset(self): def reset(self):
loop = asyncio.get_running_loop()
for task in list(self._rotating.values()) + list(self._refilling.values()):
if not task.done() and task.get_loop() is loop:
task.cancel()
for bucket in self._idle.values():
for ws, _ in bucket:
try:
ws.writer.close()
except Exception as exc:
log.debug("WS pool close failed: %r", exc)
self._idle.clear() self._idle.clear()
self._refilling.clear() self._refilling.clear()
self._rotating.clear()
self._refill_failures.clear()
self._refill_after.clear()
self.try_fronting_first = False
async def close(self):
tasks = list(self._rotating.values()) + list(self._refilling.values())
self.reset()
await asyncio.gather(*tasks, return_exceptions=True)
class _CfWorkerPool: class _CfWorkerPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 100.0
PER_DC_LIMIT = 1
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, str], deque] = {} self._idle: Dict[int, deque] = {}
self._refilling: Set[Tuple[int, str]] = set() self._refilling: Set[int] = set()
self._exhausted_until: Dict[str, float] = {}
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]: async def get(self, dc: int, fallback_dst: str,
worker_domains: List[str]
) -> Optional[Tuple[RawWebSocket, str]]:
now = time.monotonic() now = time.monotonic()
key = (dc, worker_domain)
bucket = self._idle.get(key) bucket = self._idle.get(dc)
if bucket is None: if bucket is None:
bucket = deque() bucket = deque()
self._idle[key] = bucket self._idle[dc] = bucket
while bucket: while bucket:
ws, created = bucket.popleft() ws, created, worker_domain = bucket.popleft()
age = now - created age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()): or ws.writer.transport.is_closing()):
asyncio.create_task(self._quiet_close(ws)) asyncio.create_task(self._quiet_close(ws))
continue continue
stats.cf_pool_hits += 1 stats.cf_pool_hits += 1
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)", log.debug(
dc, age, len(bucket)) "CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
self._schedule_refill(key, fallback_dst) dc, worker_domain, age, len(bucket))
return ws self._schedule_refill(dc, fallback_dst, worker_domains)
return ws, worker_domain
stats.cf_pool_misses += 1 stats.cf_pool_misses += 1
self._schedule_refill(key, fallback_dst)
return None return None
def _schedule_refill(self, key, fallback_dst): def _schedule_refill(self, dc, fallback_dst, worker_domains):
if key in self._refilling: if dc in self._refilling:
return return
self._refilling.add(key) self._refilling.add(dc)
asyncio.create_task(self._refill(key, fallback_dst)) asyncio.create_task(self._refill(
dc, fallback_dst, list(worker_domains)))
async def _refill(self, key, fallback_dst): async def _refill(self, dc, fallback_dst, worker_domains):
dc, worker_domain = key
try: try:
bucket = self._idle.setdefault(key, deque()) bucket = self._idle.setdefault(dc, deque())
needed = proxy_config.pool_size - len(bucket) target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
needed = target_size - len(bucket)
if needed <= 0: if needed <= 0:
return return
tasks = [asyncio.create_task(
self._connect_one(worker_domain, fallback_dst, dc)) for _ in range(needed):
for _ in range(needed)] connected = await self._connect_one(
for t in tasks: worker_domains, fallback_dst, dc)
try: if connected is None:
ws = await t break
if ws: ws, worker_domain = connected
bucket.append((ws, time.monotonic())) bucket.append((ws, time.monotonic(), worker_domain))
except Exception:
pass
log.debug("CF worker pool refilled DC%d: %d ready", log.debug("CF worker pool refilled DC%d: %d ready",
dc, len(bucket)) dc, len(bucket))
finally: finally:
self._refilling.discard(key) self._refilling.discard(dc)
@staticmethod async def _connect_one(self, worker_domains, fallback_dst, dc):
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
query = urlencode({ query = urlencode({
'dst': fallback_dst, 'dst': fallback_dst,
'dc': str(dc), 'dc': str(dc),
}) })
path = f'/apiws?{query}' path = f'/apiws?{query}'
try: for worker_domain in self.available_domains(worker_domains):
return await RawWebSocket.connect( try:
worker_domain, worker_domain, timeout=8, path=path) ws = await RawWebSocket.connect(
except Exception: worker_domain, worker_domain, timeout=8, path=path,
return None secure=not proxy_config.disable_secure)
return ws, worker_domain
except Exception as exc:
self.report_failure(worker_domain, exc)
return None
@staticmethod def available_domains(self, worker_domains: List[str]) -> List[str]:
async def _quiet_close(ws): now = time.time()
domains = []
for domain in worker_domains:
if domain in domains:
continue
exhausted_until = self._exhausted_until.get(domain, 0)
if exhausted_until > now:
continue
if exhausted_until:
self._exhausted_until.pop(domain, None)
domains.append(domain)
random.shuffle(domains)
return domains
def report_failure(self, worker_domain: str, exc: Exception) -> None:
return # TODO: check status code after daily limit reached
if not isinstance(exc, WsHandshakeError) or exc.status_code != 429:
return
now = time.time()
if self._exhausted_until.get(worker_domain, 0) > now:
return
exhausted_until = now + (86400 - (now % 86400))
self._exhausted_until[worker_domain] = exhausted_until
log.warning(
"CF worker %s reached its request limit, disabled for %d seconds", worker_domain, int(exhausted_until - now))
async def _quiet_close(self, ws):
try: try:
await ws.close() await ws.close()
except Exception: except Exception:
@@ -199,16 +352,17 @@ class _CfWorkerPool:
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains: if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
return return
for worker_domain in proxy_config.cfproxy_worker_domains: worker_domains = list(proxy_config.cfproxy_worker_domains)
for dc, fallback_dst in cf_fallbacks.items(): for dc, fallback_dst in cf_fallbacks.items():
self._schedule_refill((dc, worker_domain), fallback_dst) self._schedule_refill(dc, fallback_dst, worker_domains)
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks)) log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
def reset(self): def reset(self):
self._idle.clear() self._idle.clear()
self._refilling.clear() self._refilling.clear()
self._exhausted_until.clear()
ws_pool = _WsPool() ws_pool = _WsPool()
cf_worker_pool = _CfWorkerPool() cf_worker_pool = _CfWorkerPool()
+112 -59
View File
@@ -1,5 +1,5 @@
import os import os
import ssl import logging
import base64 import base64
import struct import struct
import asyncio import asyncio
@@ -7,6 +7,9 @@ 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
from .utils import create_ssl_context
log = logging.getLogger('tg-mtproto-proxy')
_st_BB = struct.Struct('>BB') _st_BB = struct.Struct('>BB')
@@ -18,10 +21,8 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
_st_H = struct.Struct('>H') _st_H = struct.Struct('>H')
_st_Q = struct.Struct('>Q') _st_Q = struct.Struct('>Q')
_ssl_ctx = ssl.create_default_context() _ssl_ctx = create_ssl_context()
_ssl_ctx.check_hostname = False _ssl_ctx_fronting = create_ssl_context(check_hostname=False)
_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,
@@ -64,46 +65,65 @@ def set_sock_opts(transport, buffer_size):
class RawWebSocket: class RawWebSocket:
__slots__ = ('reader', 'writer', '_closed') __slots__ = ('reader', 'writer', '_closed', '_frag')
OP_CONT = 0x0
OP_BINARY = 0x2 OP_BINARY = 0x2
OP_CLOSE = 0x8 OP_CLOSE = 0x8
OP_PING = 0x9 OP_PING = 0x9
OP_PONG = 0xA OP_PONG = 0xA
MAX_MESSAGE_LEN = 16 * 1024 * 1024
def __init__(self, reader: asyncio.StreamReader, def __init__(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter): writer: asyncio.StreamWriter):
self.reader = reader self.reader = reader
self.writer = writer self.writer = writer
self._closed = False self._closed = False
self._frag = bytearray()
@staticmethod @staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0, async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws') -> 'RawWebSocket': path: str = '/apiws', *,
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
ssl_context = _ssl_ctx_fronting if sni else _ssl_ctx
if sni is None:
sni = domain
reader, writer = await asyncio.wait_for( reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 443, ssl=_ssl_ctx, (
server_hostname=domain), asyncio.open_connection(
timeout=min(timeout, 10)) host, 443,
ssl=ssl_context,
set_sock_opts(writer.transport, proxy_config.buffer_size) server_hostname=sni,
)
ws_key = base64.b64encode(os.urandom(16)).decode() if secure
else asyncio.open_connection(host, 80)
req = ( ),
f'GET {path} HTTP/1.1\r\n' timeout=min(timeout, 10),
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'\r\n'
) )
writer.write(req.encode())
await writer.drain()
response_lines: list[str] = []
try: try:
set_sock_opts(writer.transport, proxy_config.buffer_size)
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'\r\n'
)
writer.write(req.encode())
await writer.drain()
response_lines: list[str] = []
while True: while True:
line = await asyncio.wait_for(reader.readline(), line = await asyncio.wait_for(reader.readline(),
timeout=timeout) timeout=timeout)
@@ -111,34 +131,32 @@ class RawWebSocket:
break break
response_lines.append( response_lines.append(
line.decode('utf-8', errors='replace').strip()) line.decode('utf-8', errors='replace').strip())
except asyncio.TimeoutError:
if not response_lines:
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
except BaseException:
writer.close() writer.close()
raise raise
if not response_lines:
writer.close()
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
writer.close()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
async def send(self, data: bytes): async def send(self, data: bytes):
if self._closed: if self._closed:
raise ConnectionError("WebSocket closed") raise ConnectionError("WebSocket closed")
@@ -156,10 +174,13 @@ class RawWebSocket:
async def recv(self) -> Optional[bytes]: async def recv(self) -> Optional[bytes]:
while not self._closed: while not self._closed:
opcode, payload = await self._read_frame() opcode, payload, fin = await self._read_frame()
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,
@@ -181,8 +202,18 @@ class RawWebSocket:
if opcode == self.OP_PONG: if opcode == self.OP_PONG:
continue continue
if opcode in (0x1, 0x2): if opcode in (self.OP_CONT, 0x1, self.OP_BINARY):
return payload if fin and not self._frag:
return payload
self._frag.extend(payload)
if len(self._frag) > self.MAX_MESSAGE_LEN:
raise ConnectionError(
f"WS message too large: {len(self._frag)} bytes")
if not fin:
continue
message = bytes(self._frag)
self._frag.clear()
return message
continue continue
return None return None
@@ -202,6 +233,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:
@@ -221,17 +271,20 @@ class RawWebSocket:
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
async def _read_frame(self) -> Tuple[int, bytes]: async def _read_frame(self) -> Tuple[int, bytes, bool]:
hdr = await self.reader.readexactly(2) hdr = await self.reader.readexactly(2)
fin = bool(hdr[0] & 0x80)
opcode = hdr[0] & 0x0F opcode = hdr[0] & 0x0F
length = hdr[1] & 0x7F length = hdr[1] & 0x7F
if length == 126: if length == 126:
length = _st_H.unpack(await self.reader.readexactly(2))[0] length = _st_H.unpack(await self.reader.readexactly(2))[0]
elif length == 127: elif length == 127:
length = _st_Q.unpack(await self.reader.readexactly(8))[0] length = _st_Q.unpack(await self.reader.readexactly(8))[0]
if length > self.MAX_MESSAGE_LEN:
raise ConnectionError(f"WS frame too large: {length} bytes")
if hdr[1] & 0x80: if hdr[1] & 0x80:
mask_key = await self.reader.readexactly(4) mask_key = await self.reader.readexactly(4)
payload = await self.reader.readexactly(length) payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key) return opcode, _xor_mask(payload, mask_key), fin
payload = await self.reader.readexactly(length) payload = await self.reader.readexactly(length)
return opcode, payload return opcode, payload, fin
+11 -1
View File
@@ -5,8 +5,13 @@ class _Stats:
self.connections_total = 0 self.connections_total = 0
self.connections_active = 0 self.connections_active = 0
self.connections_ws = 0 self.connections_ws = 0
self.connections_h2 = 0
self.h2_tcp_connections = 0
self.h2_requests = 0
self.h2_errors = 0
self.connections_tcp_fallback = 0 self.connections_tcp_fallback = 0
self.connections_cfproxy = 0 self.connections_cfproxy = 0
self.connections_fronting = 0
self.connections_bad = 0 self.connections_bad = 0
self.connections_masked = 0 self.connections_masked = 0
self.ws_errors = 0 self.ws_errors = 0
@@ -27,8 +32,13 @@ class _Stats:
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"h2={self.connections_h2} "
f"h2_tcp={self.h2_tcp_connections} "
f"h2_req={self.h2_requests} "
f"h2_err={self.h2_errors} "
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"front={self.connections_fronting} "
f"bad={self.connections_bad} " f"bad={self.connections_bad} "
f"masked={self.connections_masked} " f"masked={self.connections_masked} "
f"err={self.ws_errors} " f"err={self.ws_errors} "
@@ -38,4 +48,4 @@ class _Stats:
f"down={human_bytes(self.bytes_down)}") f"down={human_bytes(self.bytes_down)}")
stats = _Stats() stats = _Stats()
+170 -141
View File
@@ -8,10 +8,9 @@ import asyncio
import hashlib import hashlib
import argparse import argparse
import logging import logging
import logging.handlers
import socket as _socket import socket as _socket
from typing import Dict, Optional, Set, Tuple from typing import Optional, Set, Tuple
if __name__ == '__main__' and (__package__ is None or __package__ == ''): if __name__ == '__main__' and (__package__ is None or __package__ == ''):
@@ -23,20 +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, coerce_domain_list 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, reset_tcp_backoff
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts from .raw_websocket import set_sock_opts
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
from .balancer import balancer from .balancer import balancer
from .pool import ws_pool, cf_worker_pool from .pool import ws_pool, cf_worker_pool
from .cf_h2 import CfH2Pool
from .network_debug import log_ws_flow
from ._aes import Cipher, algorithms, modes from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
DC_FAIL_COOLDOWN = 30.0 LISTENER_CHECK_INTERVAL = 5.0
WS_FAIL_TIMEOUT = 2.0 LISTENER_RESTART_DELAY = 1.0
ws_blacklist: Set[str] = set() cf_h2_pool: Optional[CfH2Pool] = None
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]]:
@@ -272,6 +272,11 @@ async def _handle_client(reader, writer, secret: bytes):
dc, is_media, proto_tag, client_dec_prekey_iv = result dc, is_media, proto_tag, client_dec_prekey_iv = result
is_test_dc = proxy_config.force_test_dc or dc >= 10000
if dc >= 10000:
log.info("[%s] test DC%d -> DC%d", label, dc, dc - 10000)
dc -= 10000
if proto_tag == PROTO_TAG_ABRIDGED: if proto_tag == PROTO_TAG_ABRIDGED:
proto_int = PROTO_ABRIDGED_INT proto_int = PROTO_ABRIDGED_INT
elif proto_tag == PROTO_TAG_INTERMEDIATE: elif proto_tag == PROTO_TAG_INTERMEDIATE:
@@ -287,87 +292,11 @@ async def _handle_client(reader, writer, secret: bytes):
relay_init = _generate_relay_init(proto_tag, dc_idx) relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init) ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
dc_key = f'{dc}{"m" if is_media else ""}'
media_tag = " media" if is_media else "" media_tag = " media" if is_media else ""
ws = await ws_pool.get(dc, is_media, is_test_dc=is_test_dc)
# Fallback if DC not in config or WS blacklisted for this DC/is_media
if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
if dc not in proxy_config.dc_redirects:
log.info("[%s] DC%d not in config -> fallback",
label, dc)
else:
log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag)
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
now = time.monotonic()
fail_until = dc_fail_until.get(dc_key, 0)
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
domains = ws_domains(dc, is_media)
target = proxy_config.dc_redirects[dc]
ws = None
ws_failed_redirect = False
all_redirects = True
ws = await ws_pool.get(dc, is_media, target, domains)
if ws:
log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target)
else:
for domain in domains:
url = f'wss://{domain}/apiws'
log.info("[%s] DC%d%s -> %s via %s",
label, dc, media_tag, url, target)
try:
ws = await RawWebSocket.connect(target, domain,
timeout=ws_timeout)
all_redirects = False
break
except WsHandshakeError as exc:
stats.ws_errors += 1
if exc.is_redirect:
ws_failed_redirect = True
log.warning("[%s] DC%d%s got %d from %s -> %s",
label, dc, media_tag,
exc.status_code, domain,
exc.location or '?')
continue
else:
all_redirects = False
log.warning("[%s] DC%d%s WS handshake: %s",
label, dc, media_tag, exc.status_line)
except Exception as exc:
stats.ws_errors += 1
all_redirects = False
log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, repr(exc))
# WS failed -> fallback
if ws is None: if ws is None:
if ws_failed_redirect and all_redirects: log.info("[%s] DC%d%s WS pool unavailable -> fallback",
ws_blacklist.add(dc_key) label, dc, media_tag)
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
label, dc, media_tag)
elif ws_failed_redirect:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS cooldown for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
splitter_fb = None splitter_fb = None
try: try:
splitter_fb = MsgSplitter(relay_init, proto_int) splitter_fb = MsgSplitter(relay_init, proto_int)
@@ -375,14 +304,15 @@ async def _handle_client(reader, writer, secret: bytes):
pass pass
ok = await do_fallback( ok = await do_fallback(
clt_reader, clt_writer, relay_init, label, clt_reader, clt_writer, relay_init, label,
dc, is_media, media_tag, dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter_fb) ctx, splitter=splitter_fb, h2_pool=cf_h2_pool, proto_tag=proto_tag)
if ok: if ok:
log.info("[%s] DC%d%s fallback closed", log.info("[%s] DC%d%s fallback closed", label, dc, media_tag)
label, dc, media_tag) else:
log.warning("[%s] DC%d%s no fallback available", label, dc, media_tag)
return return
dc_fail_until.pop(dc_key, None) log.info("[%s] DC%d%s -> WS pool hit", label, dc, media_tag)
stats.connections_ws += 1 stats.connections_ws += 1
splitter = None splitter = None
@@ -429,25 +359,28 @@ _client_tasks: Set[asyncio.Task] = set()
async def _run(stop_event: Optional[asyncio.Event] = None): async def _run(stop_event: Optional[asyncio.Event] = None):
global _server_instance, _server_stop_event global _server_instance, _server_stop_event, cf_h2_pool
_server_stop_event = stop_event _server_stop_event = stop_event
reset_tcp_backoff()
ws_pool.reset() ws_pool.reset()
cf_worker_pool.reset() cf_worker_pool.reset()
ws_blacklist.clear()
dc_fail_until.clear()
_client_tasks.clear() _client_tasks.clear()
cf_h2_pool = CfH2Pool() if proxy_config.h2_enabled else None
if proxy_config.fallback_cfproxy: user_cf_domains = proxy_config.cfproxy_user_domains
user = proxy_config.cfproxy_user_domains if user_cf_domains:
if user: balancer.update_domains_list(user_cf_domains)
balancer.update_domains_list(user) else:
else: start_cfproxy_domain_refresh()
start_cfproxy_domain_refresh()
secret_bytes = bytes.fromhex(proxy_config.secret) secret_bytes = bytes.fromhex(proxy_config.secret)
stopping = False
def client_cb(r, w): def client_cb(r, w):
if stopping:
w.close()
return
task = asyncio.create_task(_handle_client(r, w, secret_bytes)) task = asyncio.create_task(_handle_client(r, w, secret_bytes))
_client_tasks.add(task) _client_tasks.add(task)
task.add_done_callback(_client_tasks.discard) task.add_done_callback(_client_tasks.discard)
@@ -484,11 +417,15 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
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:
user_domain = "user" if proxy_config.cfproxy_user_domains else "auto" user_domain = ", ".join(proxy_config.cfproxy_user_domains) if proxy_config.cfproxy_user_domains else "auto"
log.info(" CF proxy: enabled (%s)", user_domain) log.info(" CF proxy: enabled (%s)", user_domain)
if proxy_config.cfproxy_worker_domains: if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)", log.info(" CF worker: enabled (%s)",
", ".join(proxy_config.cfproxy_worker_domains)) ", ".join(proxy_config.cfproxy_worker_domains))
if cf_h2_pool is not None:
log.info(' CF H2 media: enabled')
if proxy_config.disable_secure:
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
log.info("=" * 60) log.info("=" * 60)
log.info(" Connect:") log.info(" Connect:")
if ftls: if ftls:
@@ -498,11 +435,25 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
log.info("=" * 60) log.info("=" * 60)
async def log_stats(): async def log_stats():
next_stats = time.monotonic() + 60
next_tick = time.monotonic() + 5
try: try:
while True: while True:
await asyncio.sleep(60) await asyncio.sleep(5)
bl = ', '.join(f'DC{k}' for k in sorted(ws_blacklist)) or 'none' now = time.monotonic()
log.info("stats: %s | ws_bl: %s", stats.summary(), bl) if log.isEnabledFor(logging.DEBUG):
if now - next_tick >= .25:
log.debug('NET LOOP late_ms=%.0f', (now - next_tick) * 1000)
log_ws_flow(now)
if cf_h2_pool is not None:
cf_h2_pool.log_flow(now)
next_tick = time.monotonic() + 5
if now < next_stats:
continue
next_stats = now + 60
log.info("stats: %s", stats.summary())
if cf_h2_pool is not None:
cf_h2_pool.log_stats()
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
@@ -511,38 +462,92 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
await ws_pool.warmup() await ws_pool.warmup()
await cf_worker_pool.warmup() await cf_worker_pool.warmup()
async def _quiet_cancel(t):
if not t.done():
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
serve_task = stop_task = watchdog_task = None
try: try:
async with server: while True:
if stop_event: serve_task = asyncio.create_task(server.serve_forever())
serve_task = asyncio.create_task(server.serve_forever()) stop_task = (asyncio.create_task(stop_event.wait())
stop_task = asyncio.create_task(stop_event.wait()) if stop_event else None)
done, _ = await asyncio.wait(
(serve_task, stop_task), async def _listener_watchdog():
return_when=asyncio.FIRST_COMPLETED, while True:
) await asyncio.sleep(LISTENER_CHECK_INTERVAL)
if stop_task in done: socks = server.sockets
server.close() if not socks or all(s.fileno() < 0 for s in socks):
await server.wait_closed() return
if not serve_task.done():
serve_task.cancel() watchdog_task = asyncio.create_task(_listener_watchdog())
try: waiters = [serve_task, watchdog_task]
await serve_task if stop_task is not None:
except asyncio.CancelledError: waiters.append(stop_task)
pass
else: done, _ = await asyncio.wait(
stop_task.cancel() waiters, return_when=asyncio.FIRST_COMPLETED)
try:
await stop_task if stop_task is not None and stop_task in done:
except asyncio.CancelledError: break
pass
else: await _quiet_cancel(watchdog_task)
await server.serve_forever() await _quiet_cancel(serve_task)
if stop_task is not None:
await _quiet_cancel(stop_task)
log.warning(
"Listening socket died, restarting server")
server.close()
try:
await server.wait_closed()
except Exception:
pass
await asyncio.sleep(LISTENER_RESTART_DELAY)
try:
server = await asyncio.start_server(
client_cb, proxy_config.host, proxy_config.port)
except OSError as exc:
log.error("Failed to restart server: %s", repr(exc))
break
_server_instance = server
for sock in server.sockets:
try:
sock.setsockopt(
_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
except (OSError, AttributeError):
pass
log.warning("Server restored, listening on %s:%d",
proxy_config.host, proxy_config.port)
finally: finally:
stopping = True
server.close()
clients = list(_client_tasks)
for task in clients:
task.cancel()
await asyncio.gather(*clients, return_exceptions=True)
for task in (serve_task, watchdog_task, stop_task):
if task is not None:
await _quiet_cancel(task)
await server.wait_closed()
await ws_pool.close()
if cf_h2_pool is not None:
await cf_h2_pool.close()
cf_h2_pool = None
_server_instance = _server_stop_event = None
log_stats_task.cancel() log_stats_task.cancel()
try: try:
await log_stats_task await log_stats_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
try:
server.close()
await server.wait_closed()
except Exception:
pass
_server_instance = None _server_instance = None
@@ -560,7 +565,7 @@ def main():
ap.add_argument('--secret', type=str, default=None, ap.add_argument('--secret', type=str, default=None,
help='MTProto proxy secret (32 hex chars). ' help='MTProto proxy secret (32 hex chars). '
'Auto-generated if not provided.') 'Auto-generated if not provided.')
ap.add_argument('--dc-ip', metavar='DC:IP', action='append', ap.add_argument('--dc-ip', metavar='DC:IP', nargs='?', action='append', const=None,
help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220') help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true', ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging') help='Debug logging')
@@ -568,34 +573,51 @@ 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, 0 disables direct WS)')
ap.add_argument('--cfproxy-domain', action='append', default=None, 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 H2 media and WS fallback '
'(repeatable for multiple domains)') '(repeatable for multiple domains)')
ap.add_argument('--cfproxy-worker-domain', action='append', default=None, ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
metavar='DOMAIN', metavar='DOMAIN',
help='Cloudflare Worker domain for WS fallback ' help='Cloudflare Worker domain for WS fallback '
'(tried before other fallback methods, ' '(tried before other fallback methods, '
'repeatable for multiple domains)') 'repeatable for multiple domains)')
h2_flags = ap.add_mutually_exclusive_group()
h2_flags.add_argument('--no-h2', action='store_true',
help='Disable HTTP/2 multiplexing for CF media (enabled by default)')
h2_flags.add_argument('--cfproxy-h2-media', action='store_true', help=argparse.SUPPRESS)
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('--no-secure', action='store_true',
help='Use 80 port for CF-proxy and CF-worker connections')
ap.add_argument('--fake-tls-domain', type=str, default='', ap.add_argument('--fake-tls-domain', type=str, default='',
metavar='DOMAIN', metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given ' help='Enable Fake TLS (ee-secret) masking with the given '
'SNI domain, e.g. example.com') 'SNI domain, e.g. example.com')
ap.add_argument('--force-test-dc', action='store_true',
help='Force ALL traffic to Telegram TEST datacenters. '
'Not needed for Telegram Desktop (test DCs 10001+ '
'are detected automatically); use for clients that '
'signal test DCs as plain 1-3')
ap.add_argument('--proxy-protocol', action='store_true', ap.add_argument('--proxy-protocol', action='store_true',
help='Accept PROXY protocol v1 header ' help='Accept PROXY protocol v1 header '
'(for use behind nginx/haproxy with proxy_protocol on)') '(for use behind nginx/haproxy with proxy_protocol on)')
args = ap.parse_args() args = ap.parse_args()
if not args.dc_ip: if args.dc_ip is None:
args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] args.dc_ip = [
'2:149.154.167.220',
'4:149.154.167.220',
]
elif None in args.dc_ip:
args.dc_ip = []
try: try:
dc_redirects = parse_dc_ip_list(args.dc_ip) dc_redirects = parse_dc_ip_list(args.dc_ip)
@@ -626,8 +648,11 @@ def main():
proxy_config.fallback_cfproxy = not args.no_cfproxy proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain) proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain) proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.cfproxy_h2_media = not args.no_h2
proxy_config.disable_secure = args.no_secure
proxy_config.fake_tls_domain = args.fake_tls_domain.strip() proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol proxy_config.proxy_protocol = args.proxy_protocol
proxy_config.force_test_dc = args.force_test_dc
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',
@@ -637,19 +662,23 @@ def main():
console = logging.StreamHandler() console = logging.StreamHandler()
console.setFormatter(log_fmt) console.setFormatter(log_fmt)
console.addFilter(DomainCensorFilter())
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)
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('asyncio').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('httpcore').setLevel(logging.WARNING)
try: try:
asyncio.run(_run()) asyncio.run(_run())
+54 -6
View File
@@ -1,6 +1,11 @@
import socket as _socket import socket as _socket
import urllib.request import urllib.request
import http.client import http.client
import ssl
import logging
import re
import certifi
from typing import Optional, Dict, List from typing import Optional, Dict, List
from urllib.request import Request from urllib.request import Request
@@ -43,13 +48,22 @@ DC_DEFAULT_IPS: Dict[int, str] = {
203: '91.105.192.100' 203: '91.105.192.100'
} }
DC_TEST_IPS: Dict[int, str] = {
1: '149.154.175.10',
2: '149.154.167.40',
3: '149.154.175.117',
}
def ws_domains(dc: int, is_media) -> List[str]: WS_PATH = '/apiws'
WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media: bool) -> List[str]:
if dc == 203: if dc == 203:
dc = 2 dc = 2
if is_media is None or is_media: if not is_media:
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] return [f'kws{dc}.web.telegram.org']
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
def human_bytes(n: int) -> str: def human_bytes(n: int) -> str:
@@ -73,6 +87,32 @@ def get_link_host(host: str) -> Optional[str]:
return host return host
class DomainCensorFilter(logging.Filter):
domain_pattern = re.compile(
r'(?<![\w-])(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+'
r'[a-zA-Z]{2,}(?![\w-])'
)
def _censor_match(self, match):
domain = match.group()
normalized = domain.casefold().rstrip('.')
if normalized == 'telegram.org' or normalized.endswith('.telegram.org') or normalized.endswith('.log'):
return domain
parts = domain.split('.')
if len(parts) < 2:
return domain
return '.'.join(
part if i == len(parts) - 1 else
part[:len(part) // 2] + '*' * (len(part) - len(part) // 2)
for i, part in enumerate(parts)
)
def filter(self, record):
record.msg = self.domain_pattern.sub(self._censor_match, record.getMessage())
record.args = ()
return True
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler): class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req: Request): def https_open(self, req: Request):
host = req.host.split(":")[0] host = req.host.split(":")[0]
@@ -95,10 +135,18 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
) )
try: try:
return self.do_open(_Conn, req) return self.do_open(_Conn, req, context=self._context)
except Exception: except Exception:
return super().https_open(req) return super().https_open(req)
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
context = ssl.create_default_context(cafile=certifi.where())
context.load_default_certs()
context.check_hostname = check_hostname
return context
def build_github_opener() -> urllib.request.OpenerDirector: def build_github_opener() -> urllib.request.OpenerDirector:
return urllib.request.build_opener(_PinnedHTTPSHandler()) return urllib.request.build_opener(
_PinnedHTTPSHandler(context=create_ssl_context()))
+13 -4
View File
@@ -36,6 +36,8 @@ classifiers = [
dependencies = [ dependencies = [
"pyperclip==1.9.0", "pyperclip==1.9.0",
"certifi",
"httpx[http2]==0.28.1",
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'", "psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'", "cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
@@ -45,9 +47,9 @@ dependencies = [
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'", "cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'", "Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
"customtkinter==5.2.2; platform_system != 'Darwin'", "customtkinter==5.2.2",
"pystray==0.19.5; platform_system != 'Darwin'", "pystray==0.19.5",
"rumps==0.4.0; platform_system == 'Darwin'", "pyobjc-framework-Cocoa>=9.0; platform_system == 'Darwin'",
"Pillow==12.1.0; platform_system == 'Darwin'", "Pillow==12.1.0; platform_system == 'Darwin'",
] ]
@@ -72,5 +74,12 @@ packages = ["proxy", "ui", "utils"]
[tool.hatch.version] [tool.hatch.version]
path = "proxy/__init__.py" path = "proxy/__init__.py"
[tool.ruff]
target-version = "py38"
[tool.ruff.lint] [tool.ruff.lint]
ignore = ["F403", "F405"] select = ["E4", "E7", "E9", "F", "B", "C4"]
ignore = ["F403", "F405", "B023"]
[tool.ruff.lint.per-file-ignores]
"macos.py" = ["E402"]
View File
Whitespace-only changes.
+205
View File
@@ -0,0 +1,205 @@
import os
import asyncio
import time
import unittest
from types import SimpleNamespace
from proxy._aes import Cipher, algorithms, modes
from proxy.bridge import MsgSplitter, bridge_ws_reencrypt
from proxy.network_debug import WsActivity, log_ws_flow
from proxy.utils import (
PROTO_ABRIDGED_INT,
PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT,
)
def _relay_init() -> bytes:
return os.urandom(64)
def _encryptor(relay_init: bytes):
enc = Cipher(
algorithms.AES(relay_init[8:40]), modes.CTR(relay_init[40:56])
).encryptor()
enc.update(b'\x00' * 64)
return enc
def _abridged(payload: bytes) -> bytes:
words = len(payload) // 4
if words < 0x7F:
return bytes([words]) + payload
return b'\x7f' + words.to_bytes(3, 'little') + payload
def _intermediate(payload: bytes) -> bytes:
return len(payload).to_bytes(4, 'little') + payload
class MsgSplitterTest(unittest.TestCase):
def _split(self, proto_int, packets, chunk_sizes=None):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, proto_int)
enc = _encryptor(relay_init)
stream = enc.update(b''.join(packets))
chunks = []
if chunk_sizes is None:
chunks = [stream]
else:
offset = 0
for size in chunk_sizes:
chunks.append(stream[offset:offset + size])
offset += size
if offset < len(stream):
chunks.append(stream[offset:])
parts = []
for chunk in chunks:
parts.extend(splitter.split(chunk))
return splitter, stream, parts
def test_abridged_stream_splits_into_packets(self):
packets = [_abridged(b'a' * 4), _abridged(b'b' * 16), _abridged(b'c' * 40)]
_, stream, parts = self._split(PROTO_ABRIDGED_INT, packets)
self.assertEqual(len(parts), 3)
self.assertEqual(b''.join(parts), stream)
self.assertEqual([len(p) for p in parts], [5, 17, 41])
def test_intermediate_stream_splits_into_packets(self):
packets = [_intermediate(b'a' * 8), _intermediate(b'b' * 12)]
_, stream, parts = self._split(PROTO_INTERMEDIATE_INT, packets)
self.assertEqual(len(parts), 2)
self.assertEqual(b''.join(parts), stream)
def test_padded_intermediate_uses_intermediate_framing(self):
packets = [_intermediate(b'z' * 20)]
_, stream, parts = self._split(PROTO_PADDED_INTERMEDIATE_INT, packets)
self.assertEqual(parts, [stream])
def test_partial_packet_is_buffered_until_complete(self):
packets = [_abridged(b'a' * 20)]
_, stream, parts = self._split(
PROTO_ABRIDGED_INT, packets, chunk_sizes=[1] * (len(packets[0]) - 1)
)
self.assertEqual(parts, [stream])
def test_split_preserves_stream_across_arbitrary_chunking(self):
packets = [_intermediate(bytes([i]) * 16) for i in range(8)]
_, stream, parts = self._split(
PROTO_INTERMEDIATE_INT, packets, chunk_sizes=[7, 3, 50, 11]
)
self.assertEqual(b''.join(parts), stream)
self.assertEqual(len(parts), 8)
def test_empty_chunk_yields_nothing(self):
splitter = MsgSplitter(_relay_init(), PROTO_INTERMEDIATE_INT)
self.assertEqual(splitter.split(b''), [])
def test_zero_length_packet_disables_splitting(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
stream = enc.update((0).to_bytes(4, 'little') + b'tail')
parts = splitter.split(stream)
self.assertEqual(parts, [stream])
self.assertEqual(splitter.split(b'raw'), [b'raw'])
def test_flush_returns_buffered_tail_once(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
partial = enc.update(_intermediate(b'x' * 32)[:10])
self.assertEqual(splitter.split(partial), [])
self.assertEqual(splitter.flush(), [partial])
self.assertEqual(splitter.flush(), [])
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
class Cipher:
def update(self, data):
return data
class Socket:
def __init__(self):
self.queue = asyncio.Queue()
self.sending = asyncio.Event()
self.release = asyncio.Event()
self.sent = []
async def send(self, data):
self.sent.append(data)
self.sending.set()
await self.release.wait()
async def recv(self):
return await self.queue.get()
async def close(self):
pass
class Writer:
def __init__(self):
self.data = bytearray()
self.written = asyncio.Event()
self.release = asyncio.Event()
def write(self, data):
self.data.extend(data)
self.written.set()
async def drain(self):
await self.release.wait()
def close(self):
pass
async def wait_closed(self):
pass
ws, writer = Socket(), Writer()
reader = asyncio.StreamReader()
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
try:
reader.feed_data(b'private-native-payload')
await asyncio.wait_for(ws.sending.wait(), 1)
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
self.assertIsNotNone(activity.sending)
self.assertEqual(activity.ws_up, 0)
log_ws_flow(time.monotonic())
# A reply arriving before send drain finishes must not create
# a spurious wait for another response when drain resumes.
await ws.queue.put(b'private-upstream-payload')
await asyncio.wait_for(writer.written.wait(), 1)
self.assertEqual(activity.ws_down, len(writer.data))
self.assertEqual(activity.native_down, 0)
self.assertIsNotNone(activity.writing_native)
log_ws_flow(time.monotonic())
ws.release.set()
writer.release.set()
for _ in range(100):
if activity.native_down and activity.ws_up:
break
await asyncio.sleep(.001)
self.assertEqual(activity.native_down, len(writer.data))
self.assertIsNone(activity.awaiting_rx)
self.assertEqual(ws.sent, [b'private-native-payload'])
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
reader.feed_eof()
await asyncio.wait_for(bridge, 1)
finally:
bridge.cancel()
await asyncio.gather(bridge, return_exceptions=True)
self.assertFalse(WsActivity.active)
output = '\n'.join(captured.output)
self.assertIn('WS FLOW DC203', output)
self.assertIn('WS END DC203', output)
self.assertNotIn('private-', output)
if __name__ == '__main__':
unittest.main()
+977
View File
@@ -0,0 +1,977 @@
import asyncio
import inspect
import struct
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import httpx
from proxy.balancer import _Balancer
from proxy.cf_h2 import (
CfH2Pool, MAX_CHANNEL_REQUESTS, MAX_PACKET, _HttpChannel, _HttpLane,
_ReplayPacket, _encode_reply, _read_packet, bridge_h2,
)
from proxy.utils import PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE
class _IdentityCipher:
def update(self, value):
return value
class NativeFramingTest(unittest.IsolatedAsyncioTestCase):
async def _packet(self, wire, tag):
reader = asyncio.StreamReader()
async def feed():
for byte in wire:
reader.feed_data(bytes([byte]))
await asyncio.sleep(0)
reader.feed_eof()
task = asyncio.create_task(feed())
try:
return await _read_packet(reader, _IdentityCipher(), tag)
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def test_fragmented_abridged_extended_and_quick_ack(self):
body = b'a' * 512
decoded, quick = await self._packet(b'\xff\x80\x00\x00' + body, PROTO_TAG_ABRIDGED)
self.assertEqual(decoded, body)
self.assertTrue(quick)
async def test_intermediate_quick_ack_does_not_change_body(self):
body = b'a' * 40
decoded, quick = await self._packet(struct.pack('<I', len(body) | 0x80000000) + body,
PROTO_TAG_INTERMEDIATE)
self.assertEqual(decoded, body)
self.assertTrue(quick)
async def test_padded_plaintext_removes_all_fifteen_padding_lengths(self):
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 20) + b'p' * 20
for padding in range(16):
wire = body + b'z' * padding
decoded, quick = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
self.assertEqual(decoded, body, padding)
self.assertFalse(quick)
async def test_padded_ciphertext_removes_full_padding_blocks(self):
body = b'k' * 8 + b'm' * 16 + b'c' * 32
for padding in range(16):
wire = body + b'z' * padding
decoded, _ = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
self.assertEqual(decoded, body, padding)
async def test_oversize_header_rejected_before_body_read(self):
reader = asyncio.StreamReader()
reader.feed_data(struct.pack('<I', MAX_PACKET + 4))
with self.assertRaisesRegex(ValueError, 'length'):
await _read_packet(reader, _IdentityCipher(), PROTO_TAG_INTERMEDIATE)
def test_negative_transport_error_keeps_native_framing(self):
error = struct.pack('<i', -404)
self.assertEqual(_encode_reply(error, PROTO_TAG_ABRIDGED), b'\x01' + error)
self.assertEqual(_encode_reply(error, PROTO_TAG_INTERMEDIATE), b'\x04\x00\x00\x00' + error)
class HttpMultiplexTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.lane = _HttpLane('kws4.example.org', 1)
await self.lane.client.aclose()
self.channels = []
async def asyncTearDown(self):
await self.lane.close()
def _channel(self):
channel = _HttpChannel(self.lane, len(self.channels) + 1, 'owned-test')
self.channels.append(channel)
return channel
def _transport(self, handler, *, trace_send=True):
async def handle(request):
if trace_send and 'trace' in request.extensions:
# MockTransport receives the complete body before calling us.
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
result = handler(request)
return await result if inspect.isawaitable(result) else result
self.lane.client = httpx.AsyncClient(http2=True, transport=httpx.MockTransport(handle))
async def test_same_auth_prefix_out_of_order_replies_stay_with_source_channel(self):
first_started, release_first = asyncio.Event(), asyncio.Event()
body_a, body_b = b'samekey!' + b'a' * 32, b'samekey!' + b'b' * 32
async def handler(request):
if request.content == body_a:
first_started.set()
await release_first.wait()
return httpx.Response(200, content=request.content, extensions={
'http_version': b'HTTP/2', 'stream_id': 3 if request.content == body_a else 5})
self._transport(handler)
first, second = self._channel(), self._channel()
await first.send(body_a, False)
await first_started.wait()
await second.send(body_b, False)
self.assertEqual(await asyncio.wait_for(second.receive(), 1), body_b)
release_first.set()
self.assertEqual(await asyncio.wait_for(first.receive(), 1), body_a)
self.assertEqual(self.lane.max_inflight, 2)
async def test_close_one_channel_does_not_cancel_another(self):
async def handler(request):
if request.content[:1] == b'a':
await asyncio.Event().wait()
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
first, second = self._channel(), self._channel()
await first.send(b'a' * 40, False)
await first.close()
await second.send(b'b' * 40, False)
self.assertEqual(await asyncio.wait_for(second.receive(), 1), b'b' * 40)
await second.close()
self.assertEqual(self.lane.inflight, 0)
self.assertEqual(self.lane.queued_bytes, 0)
self.assertFalse(self.lane.closed)
async def test_backpressure_waits_instead_of_dropping_requests(self):
release = asyncio.Event()
async def handler(request):
await release.wait()
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
for _ in range(MAX_CHANNEL_REQUESTS):
await channel.send(b'x' * 40, False)
extra = asyncio.create_task(channel.send(b'y' * 40, False))
await asyncio.sleep(0.01)
self.assertFalse(extra.done())
self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS)
release.set()
await asyncio.wait_for(extra, 1)
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(MAX_CHANNEL_REQUESTS + 1)]
self.assertEqual(received.count(b'y' * 40), 1)
async def test_http_error_closes_only_its_channel_without_replaying_packet(self):
calls = []
async def handler(request):
calls.append(request.content)
return httpx.Response(503, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
await channel.send(b'x' * 40, False)
with self.assertRaisesRegex(ConnectionError, '503'):
await asyncio.wait_for(channel.receive(), 1)
self.assertEqual(calls, [b'x' * 40])
self.assertTrue(channel.closed)
self.assertFalse(self.lane.closed)
async def test_transport_error_reaches_client_before_close_without_cooling_domain(self):
class ErrorBody(httpx.AsyncByteStream):
async def __aiter__(self):
raise AssertionError('HTTP transport error bodies must be ignored')
yield b''
for tag in (PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE):
for status in (403, 404, 429, 444):
with self.subTest(tag=tag, status=status):
calls = []
def handler(request):
calls.append(request.content)
if request.content == b'h' * 40:
return httpx.Response(200, content=b'r' * 40,
extensions={'http_version': b'HTTP/2'})
return httpx.Response(status, stream=ErrorBody(),
headers={'content-type': 'text/html'},
extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
output = bytearray()
draining, release = asyncio.Event(), asyncio.Event()
async def drain():
draining.set()
await release.wait()
writer = SimpleNamespace(write=output.extend, drain=drain)
reader = asyncio.StreamReader()
packet = b'k' * 40
reader.feed_data(_encode_reply(packet, tag))
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
task = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, tag))
try:
await asyncio.wait_for(draining.wait(), 1)
self.assertFalse(task.done())
self.assertFalse(channel.close_done)
if tag == PROTO_TAG_ABRIDGED:
self.assertEqual(output, b'\x01' + struct.pack('<i', -status))
else:
length, = struct.unpack('<I', output[:4])
self.assertEqual(length, len(output) - 4)
self.assertEqual(output[4:8], struct.pack('<i', -status))
self.assertLessEqual(length, 7)
self.assertEqual(self.lane.failed_until, 0)
healthy = self._channel()
await healthy.send(b'h' * 40, False)
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'r' * 40)
self.assertEqual(calls, [packet, b'h' * 40])
self.assertFalse(self.lane.closed)
finally:
release.set()
await asyncio.wait_for(task, 1)
self.assertTrue(channel.close_done)
self.assertFalse(channel.pending)
await healthy.close()
async def test_long_polls_cannot_block_fresh_request_and_retired_packet_recovers(self):
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(MAX_CHANNEL_REQUESTS)]
fresh = b'samekey!' + b'n' * 32
calls = []
async def handler(request):
calls.append(request.content)
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': len(calls) * 2 + 1})
if request.content == fresh:
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
if request.content == bodies[0] and calls.count(bodies[0]) == 2:
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
await asyncio.Event().wait()
self._transport(handler)
channel = self._channel()
for body in bodies:
await channel.send(body, False)
await asyncio.sleep(0)
original = next(iter(channel.pending))
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(channel.send(fresh, False), 1)
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(2)]
self.assertCountEqual(received, [b'n' * 40, b'r' * 40])
self.assertTrue(original.cancelled())
self.assertTrue(channel.replay_history[0].retired)
self.assertEqual(calls.count(bodies[0]), 2)
self.assertEqual(channel.capacity_rotations, 1)
self.assertEqual(self.lane.max_inflight, MAX_CHANNEL_REQUESTS)
self.assertEqual(self.lane.errors, 0)
self.assertFalse(channel.closed)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_shared_lane_long_polls_cannot_block_new_channel(self):
fresh = b'samekey!' + b'n' * 32
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == fresh:
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
await asyncio.Event().wait()
self._transport(handler)
first, second, waiting = self._channel(), self._channel(), self._channel()
with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 2), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
await first.send(b'samekey!' + b'a' * 32, False)
await second.send(b'samekey!' + b'b' * 32, False)
await asyncio.sleep(0)
originals = [next(iter(channel.pending)) for channel in (first, second)]
recovery = asyncio.create_task(waiting._recover())
try:
await asyncio.wait_for(waiting.send(fresh, False), 1)
self.assertEqual(await asyncio.wait_for(waiting.receive(), 1), b'n' * 40)
self.assertEqual(sum(task.cancelled() for task in originals), 1)
self.assertEqual(sum(channel.capacity_rotations for channel in (first, second)), 1)
self.assertEqual(self.lane.capacity_rotations, 1)
self.assertEqual(self.lane.max_inflight, 2)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_upload_room_accounts_for_waiting_packet_size(self):
for limit in ('MAX_CHANNEL_BYTES', 'MAX_LANE_BYTES'):
with self.subTest(limit=limit):
started = asyncio.Event()
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == b'k' * 40:
started.set()
await asyncio.Event().wait()
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
with patch('proxy.cf_h2.' + limit, 60), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
await channel.send(b'k' * 40, False)
await started.wait()
recovery = asyncio.create_task(channel._recover())
try:
# 20 bytes are free, but the pending upload needs 40.
await asyncio.wait_for(channel.send(b'n' * 40, False), .5)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
self.assertEqual(channel.capacity_rotations, 1)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
await channel.close()
async def test_close_releases_request_cancelled_before_coroutine_start_once(self):
self._transport(lambda request: self.fail('cancelled request reached HTTP'))
channel = self._channel()
create_task = asyncio.create_task
def cancelled_task(coroutine):
task = create_task(coroutine)
task.cancel()
return task
with patch('proxy.cf_h2.asyncio.create_task', cancelled_task):
await channel.send(b'k' * 40, False)
packet = channel.replay_history[0]
await channel.close()
await channel.close()
self.assertEqual((self.lane.inflight, self.lane.queued_bytes, channel.pending_bytes), (0, 0, 0))
self.assertFalse(channel.pending)
self.assertFalse(channel.pending_since)
self.assertFalse(channel.sent_since)
self.assertFalse(packet.originals)
async def test_capacity_rotation_keeps_upload_and_started_response_intact(self):
uploading, receiving, waiting = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
fresh = b'samekey!' + b'n' * 32
body_started = asyncio.Event()
class SlowBody(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'r' * 20
body_started.set()
await asyncio.Event().wait()
async def handler(request):
if request.content == uploading:
await asyncio.Event().wait() # No send_request_body.complete yet.
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == receiving:
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
if request.content == waiting:
await asyncio.Event().wait()
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler, trace_send=False)
channel = self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 3), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
for body in (uploading, receiving, waiting):
await channel.send(body, False)
await asyncio.wait_for(body_started.wait(), 1)
tasks = list(channel.pending)
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(channel.send(fresh, False), 1)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
self.assertFalse(tasks[0].done())
self.assertFalse(tasks[1].done())
self.assertTrue(tasks[2].cancelled())
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_capacity_rotation_never_retires_unretained_or_plaintext_request(self):
release = asyncio.Event()
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
await release.wait()
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 2), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002):
await channel.send(b'\0' * 8 + b'p' * 32, False)
await channel.send(b'samekey!' + b'u' * (64 * 1024), False)
extra = asyncio.create_task(channel.send(b'samekey!' + b'n' * 32, False))
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.sleep(.04)
self.assertFalse(extra.done())
self.assertEqual(channel.capacity_rotations, 0)
self.assertTrue(all(not task.done() for task in channel.pending))
release.set()
await asyncio.wait_for(extra, 1)
finally:
release.set()
extra.cancel()
recovery.cancel()
await asyncio.gather(extra, recovery, return_exceptions=True)
async def test_http1_rejected_by_preflight(self):
self._transport(lambda request: httpx.Response(200, extensions={'http_version': b'HTTP/1.1'}))
with self.assertRaisesRegex(ValueError, 'HTTP/1.1'):
await self.lane._preflight()
async def test_telegram_head_501_still_allows_http2_preflight(self):
self._transport(lambda request: httpx.Response(501, extensions={'http_version': b'HTTP/2'}))
await self.lane._preflight()
async def test_missing_http_endpoint_is_rejected_before_native_requests(self):
self._transport(lambda request: httpx.Response(
404, extensions={'http_version': b'HTTP/2'}))
with self.assertRaisesRegex(ConnectionError, 'preflight HTTP 404'):
await self.lane._preflight()
async def _packet_bridge(self, *, content_length=True, truncate=False, concurrent_small=False):
first_chunk_read, release_tail = asyncio.Event(), asyncio.Event()
body = b'x' * (64 * 1024 + 40)
first_chunk = body[:16384]
class ResponseStream(httpx.AsyncByteStream):
async def __aiter__(self):
yield first_chunk
first_chunk_read.set()
await release_tail.wait()
if not truncate:
yield body[len(first_chunk):]
async def handler(request):
if request.content == b's' * 40:
return httpx.Response(200, content=b'z' * 40,
extensions={'http_version': b'HTTP/2'})
headers = {'content-length': str(len(body))} if content_length else {}
return httpx.Response(200, headers=headers, stream=ResponseStream(),
extensions={'http_version': b'HTTP/2'})
self._transport(handler)
class Writer:
def __init__(self):
self.body = bytearray()
self.changed = asyncio.Event()
def write(self, data):
self.body.extend(data)
self.changed.set()
async def drain(self):
await asyncio.sleep(0)
reader, writer = asyncio.StreamReader(), Writer()
channel = self._channel()
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
task = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
reader.feed_data(struct.pack('<I', 40) + b'l' * 40)
try:
await asyncio.wait_for(first_chunk_read.wait(), 1)
self.assertEqual(writer.body, b'')
if concurrent_small:
reader.feed_data(struct.pack('<I', 40) + b's' * 40)
await asyncio.wait_for(writer.changed.wait(), 1)
self.assertEqual(writer.body, struct.pack('<I', 40) + b'z' * 40)
release_tail.set()
if truncate:
await asyncio.wait_for(task, 1)
self.assertTrue(channel.closed)
self.assertEqual(writer.body, b'')
else:
expected = struct.pack('<I', len(body)) + body
if concurrent_small:
expected = struct.pack('<I', 40) + b'z' * 40 + expected
for _ in range(100):
if len(writer.body) >= len(expected):
break
await asyncio.sleep(0)
self.assertEqual(writer.body, expected)
finally:
release_tail.set()
reader.feed_eof()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def test_native_frame_starts_only_after_entire_body_is_available(self):
await self._packet_bridge()
async def test_ready_reply_bypasses_incomplete_large_response_on_same_channel(self):
await self._packet_bridge(concurrent_small=True)
async def test_response_without_length_uses_buffered_framing(self):
await self._packet_bridge(content_length=False)
async def test_truncated_stream_closes_native_channel(self):
await self._packet_bridge(truncate=True)
async def test_recovery_pauses_during_body_receive_and_resumes_afterwards(self):
started, release = asyncio.Event(), asyncio.Event()
calls = []
class SlowBody(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'r' * 20
started.set()
await release.wait()
yield b'r' * 20
def handler(request):
calls.append(request.content)
if len(calls) == 1:
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
return httpx.Response(200, content=b's' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
await channel.send(b'x' * 40, False)
await asyncio.wait_for(started.wait(), 1)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.sleep(.06)
self.assertEqual(len(calls), 1)
self.assertEqual(channel.receiving_http, 1)
release.set()
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b's' * 40)
self.assertEqual(calls, [b'x' * 40, b'x' * 40])
self.assertEqual(channel.receiving_http, 0)
self.assertEqual(self.lane.reply_buffer_bytes, 0)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_lane_reply_memory_bound_and_cancellation_cleanup(self):
started = asyncio.Event()
class SlowBody(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'r' * 32
started.set()
await asyncio.Event().wait()
def handler(request):
if request.content == b'a' * 40:
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
return httpx.Response(200, content=b'r' * 16, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
slow = self._channel()
with patch('proxy.cf_h2.MAX_LANE_REPLY_BYTES', 40):
await slow.send(b'a' * 40, False)
await asyncio.wait_for(started.wait(), 1)
self.assertEqual(self.lane.reply_buffer_bytes, 32)
with self.assertRaisesRegex(BufferError, 'response buffers'):
await self.lane._post(b'b' * 40, 2)
self.assertEqual(self.lane.reply_buffer_bytes, 32)
self.assertEqual(self.lane.failed_until, 0)
await slow.close()
self.assertEqual(slow.receiving_http, 0)
self.assertEqual(self.lane.reply_buffer_bytes, 0)
self.assertEqual(await self.lane._post(b'b' * 40, 2), b'r' * 16)
async def test_native_disconnect_does_not_cool_down_healthy_lane(self):
self._transport(lambda request: httpx.Response(
200, content=b'r' * 65536, extensions={'http_version': b'HTTP/2'}))
class DisconnectedWriter:
def write(self, data):
pass
async def drain(self):
raise ConnectionResetError('Connection lost')
channel = self._channel()
reader = asyncio.StreamReader()
reader.feed_data(struct.pack('<I', 40) + b'x' * 40)
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
await asyncio.wait_for(bridge_h2(reader, DisconnectedWriter(), channel, ctx,
PROTO_TAG_INTERMEDIATE), 1)
self.assertTrue(channel.closed)
self.assertEqual(self.lane.failed_until, 0)
self.assertEqual(self.lane.errors, 0)
healthy = self._channel()
await healthy.send(b'h' * 40, False)
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'r' * 65536)
async def test_slow_receiver_queue_overflow_does_not_break_other_channels(self):
async def handler(request):
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
slow, healthy = self._channel(), self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_BYTES', 80):
for _ in range(3):
await slow.send(b's' * 40, False)
await asyncio.gather(*list(slow.pending))
with self.assertRaises(BufferError):
await asyncio.wait_for(slow.receive(), 1)
await healthy.send(b'h' * 40, False)
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'h' * 40)
self.assertFalse(self.lane.closed)
async def test_simultaneous_pool_opens_preflight_once_and_share_client(self):
pool = CfH2Pool()
balancer = _Balancer()
balancer.update_domains_list(['example.org'])
started = []
async def preflight(lane):
started.append(lane)
await asyncio.sleep(0)
try:
with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight):
channels = await asyncio.gather(*(pool.open(4, 'owned-test') for _ in range(6)))
self.assertEqual(len(started), 1)
self.assertEqual(len({id(channel.lane.client) for channel in channels}), 1)
self.assertEqual(len({channel.channel_id for channel in channels}), 6)
finally:
await pool.close()
async def test_recovery_reuses_exact_ciphertext_when_original_http_is_still_pending(self):
calls = []
original_waiting = asyncio.Event()
body = b'opaque-key-and-message-id' + b'x' * 16
async def handler(request):
calls.append(request.content)
if len(calls) == 1:
original_waiting.set()
await asyncio.Event().wait()
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
await channel.send(body, False)
await original_waiting.wait()
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
self.assertEqual(calls, [body, body])
self.assertEqual(channel.replay_requests, 1)
self.assertEqual(self.lane.max_inflight, 2)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
await channel.close()
self.assertFalse(channel.pending)
self.assertEqual(self.lane.inflight, 0)
async def test_recovery_after_http_finished_without_native_reply(self):
calls = []
async def handler(request):
calls.append(request.content)
return httpx.Response(200, content=b'' if len(calls) == 1 else b'r' * 40,
extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
body = b'k' * 40
await channel.send(body, False)
await asyncio.gather(*list(channel.pending))
self.assertTrue(channel.queue.empty())
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
await asyncio.sleep(.03)
self.assertEqual(calls, [body, body])
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_idle_receiver_preserves_capacity_for_new_native_request(self):
calls = []
probes_waiting = asyncio.Event()
async def handler(request):
calls.append(request.content)
if calls.count(request.content) > 1:
if len(calls) == 4:
probes_waiting.set()
await asyncio.Event().wait()
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)]
for body in bodies[:3]:
await channel.send(body, False)
await asyncio.gather(*list(channel.pending))
await channel.receive()
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(probes_waiting.wait(), 1)
await asyncio.sleep(.03)
self.assertEqual(channel.replay_requests, 1)
self.assertEqual(len(channel.replay_pending), 1)
await asyncio.wait_for(channel.send(bodies[3], False), 1)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
self.assertEqual(calls[3:4], [bodies[2]])
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
await asyncio.gather(channel.close(), channel.close())
self.assertEqual(self.lane.inflight, 0)
self.assertEqual(channel.pending_bytes, 0)
self.assertFalse(channel.replay_pending)
async def test_handshake_and_expired_ciphertext_are_never_replayed(self):
calls = []
async def handler(request):
calls.append(request.content)
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
for body in (b'\x00' * 8 + b'h' * 32, b'k' * 40):
await channel.send(body, False)
await asyncio.gather(*list(channel.pending))
await channel.receive()
# Windows may give consecutive monotonic() calls the same timestamp;
# an event wakeup need not advance the clock like the old sleep did.
channel.replay_history[-1].sent_at -= 1
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_MAX_AGE', 0):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.sleep(.04)
self.assertEqual(len(calls), 2)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_unhelpful_replies_do_not_cause_unbounded_recovery(self):
calls = []
retries_finished = asyncio.Event()
async def handler(request):
calls.append((request.content, time.monotonic()))
if len(calls) == 5:
retries_finished.set()
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)]
for body in bodies:
await channel.send(body, False)
await asyncio.gather(*list(channel.pending))
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(retries_finished.wait(), 1)
await asyncio.sleep(.06)
older = [when for body, when in calls if body == bodies[0]]
latest = [when for body, when in calls if body == bodies[1]]
# Completed history is not scanned; only the latest is polled.
self.assertEqual(len(older), 1)
self.assertEqual(len(latest), 4)
for first, second in zip(latest[1:], latest[2:]):
self.assertGreaterEqual(second - first, .015)
self.assertFalse(channel.pending)
self.assertFalse(channel.replay_pending)
self.assertEqual(self.lane.inflight, 0)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_old_pending_request_has_bounded_retries_after_newer_requests(self):
body = b'samekey!' + b't' * 32
original_started = asyncio.Event()
retries_finished = asyncio.Event()
attempts = 0
async def handler(request):
nonlocal attempts
if request.content == body:
attempts += 1
if attempts == 1:
original_started.set()
await asyncio.Event().wait()
if attempts == 4:
retries_finished.set()
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
await channel.send(body, False)
await original_started.wait()
original = next(iter(channel.pending))
await channel.send(b'samekey!' + b'n' * 32, False)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(retries_finished.wait(), 1)
await asyncio.sleep(.06)
self.assertEqual(attempts, 4)
self.assertFalse(original.done())
self.assertFalse(channel.replay_pending)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_closed_pool_cannot_open_new_channels(self):
pool = CfH2Pool()
await pool.close()
with self.assertRaisesRegex(ConnectionError, 'pool closed'):
await pool.open(4, 'owned-test')
async def test_concurrent_upload_and_recovery_cannot_exceed_channel_request_bound(self):
release = asyncio.Event()
async def handler(request):
await release.wait()
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
for _ in range(MAX_CHANNEL_REQUESTS - 1):
await channel.send(b'k' * 40, False)
first = asyncio.create_task(channel.send(b'a' * 40, False))
second = asyncio.create_task(channel.send(b'b' * 40, False))
try:
await asyncio.wait_for(first, 1)
await asyncio.sleep(.02)
self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS)
self.assertFalse(second.done())
release.set()
await asyncio.wait_for(second, 1)
finally:
release.set()
first.cancel()
second.cancel()
await asyncio.gather(first, second, return_exceptions=True)
async def test_failing_preflight_returns_to_ws_without_consuming_native_data(self):
pool = CfH2Pool()
balancer = _Balancer()
balancer.update_domains_list(['example.org'])
async def preflight(lane):
raise OSError('owned connection failure')
try:
with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight):
self.assertIsNone(await pool.open(4, 'owned-test'))
self.assertFalse(pool.lanes)
finally:
await pool.close()
async def test_hanging_setup_returns_to_ws_and_cleans_up_lane(self):
pool = CfH2Pool()
balancer = _Balancer()
balancer.update_domains_list(['example.org'])
lanes = []
async def preflight(lane):
lanes.append(lane)
await asyncio.Event().wait()
try:
with patch('proxy.cf_h2.balancer', balancer), \
patch('proxy.cf_h2.SETUP_TIMEOUT', .02), \
patch.object(_HttpLane, '_preflight', preflight):
self.assertIsNone(await pool.open(4, 'owned-test'))
self.assertIsNone(await pool.open(4, 'owned-test'))
self.assertEqual(len(lanes), 1)
self.assertTrue(lanes[0].closed)
self.assertTrue(lanes[0].client.is_closed)
self.assertFalse(pool.lanes)
finally:
await pool.close()
async def test_recovery_does_not_wait_for_channel_or_lane_capacity(self):
channel = self._channel()
body = b'k' * 40
channel.replay_history.append(_ReplayPacket(0, body, 0))
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 0):
self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1))
with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 0):
self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1))
self.assertFalse(channel.send_lock.locked())
self.assertFalse(channel.replay_history[0].replayed)
self.assertEqual(channel.requests, 0)
self.assertEqual(self.lane.inflight, 0)
async def test_recovery_does_not_queue_behind_native_upload(self):
channel = self._channel()
async with channel.send_lock:
self.assertFalse(await asyncio.wait_for(channel.send(b'k' * 40, False, replay=True), .1))
self.assertEqual(channel.requests, 0)
async def test_wait_diagnostics_combine_channels_and_are_rate_limited(self):
async def handler(request):
await asyncio.Event().wait()
self._transport(handler)
now = time.monotonic()
for _ in range(2):
channel = self._channel()
await channel.send(b'k' * 40, False)
channel.last_progress = now - 4
channel.pending_since = dict.fromkeys(channel.pending, now - 4)
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
for _ in range(20):
self.lane.log_wait(now)
self.assertEqual(len(captured.records), 1)
self.assertIn('H2 WAIT', captured.output[0])
self.assertIn('affected=2', captured.output[0])
self.assertIn('channel=1', captured.output[0])
self.assertIn('channel=2', captured.output[0])
self.assertIn('recovery_left=', captured.output[0])
async def test_new_request_after_idle_does_not_log_a_stall(self):
async def handler(request):
await asyncio.Event().wait()
self._transport(handler)
channel = self._channel()
await channel.send(b'k' * 40, False)
now = time.monotonic()
channel.last_progress = now - 20
with patch('proxy.cf_h2.log.isEnabledFor', return_value=True), \
patch('proxy.cf_h2.log.debug') as debug:
self.lane.log_wait(now)
debug.assert_not_called()
async def test_wait_diagnostics_include_old_request_while_other_replies_progress(self):
async def handler(request):
await asyncio.Event().wait()
self._transport(handler)
channel = self._channel()
await channel.send(b'k' * 40, False)
now = time.monotonic()
channel.pending_since = dict.fromkeys(channel.pending, now - 4)
channel.last_progress = now
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
self.lane.log_wait(now)
self.assertEqual(len(captured.records), 1)
self.assertIn('waiting_headers=1', captured.output[0])
self.assertIn('idle_ms=0', captured.output[0])
async def test_slow_response_diagnostics_are_rate_limited(self):
self._transport(lambda request: httpx.Response(
200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'}))
with patch('proxy.cf_h2.SLOW_RESPONSE_SECONDS', 0), \
self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
for _ in range(10):
await self.lane._post(b'k' * 40, 1)
self.assertEqual(len(captured.records), 1)
self.assertIn('H2 SLOW', captured.output[0])
self.assertIn('headers_ms=', captured.output[0])
self.assertIn('body_ms=', captured.output[0])
async def test_pending_details_link_replay_to_original_and_bound_history(self):
calls = 0
original_waiting = asyncio.Event()
async def handler(request):
nonlocal calls
calls += 1
if calls == 1:
original_waiting.set()
await asyncio.Event().wait()
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
await channel.send(b'samekey!' + b'p' * 32, False)
await original_waiting.wait()
original = next(iter(channel.pending))
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', 0):
await channel.send(b'samekey!' + b'p' * 32, False, replay=True)
await asyncio.gather(*(task for task in channel.pending if task is not original))
trace = channel.request_traces[original]
replay = channel.recent_traces[-1]
self.assertEqual(replay.packet, trace.packet)
self.assertNotEqual(replay.request, trace.request)
self.assertTrue(replay.replay)
now = time.monotonic()
trace.started = now - 4
channel.pending_since[original] = now - 4
self.lane.log_wait(now)
for index in range(24):
await channel.send(b'samekey!' + bytes([index]) * 32, False)
await asyncio.gather(*(task for task in channel.pending if task is not original))
self.assertEqual(len(channel.recent_traces), 16)
self.assertEqual(len(channel.request_traces), 1)
output = '\n'.join(captured.output)
self.assertIn('H2 DETAIL', output)
self.assertIn('pkt=1', output)
self.assertIn('replay=1 phase=complete', output)
self.assertNotIn('samekey!', output)
+87
View File
@@ -0,0 +1,87 @@
import unittest
from proxy.config import (
CFPROXY_DEFAULT_DOMAINS,
_is_valid_domain,
_normalize_domain_pool,
coerce_domain_list,
parse_dc_ip_list,
)
class ParseDcIpListTest(unittest.TestCase):
def test_parses_multiple_entries(self):
self.assertEqual(
parse_dc_ip_list(['2:149.154.167.220', '4:1.2.3.4']),
{2: '149.154.167.220', 4: '1.2.3.4'},
)
def test_last_entry_wins_for_duplicate_dc(self):
self.assertEqual(parse_dc_ip_list(['2:1.1.1.1', '2:2.2.2.2']), {2: '2.2.2.2'})
def test_rejects_missing_separator(self):
with self.assertRaises(ValueError) as ctx:
parse_dc_ip_list(['2-1.2.3.4'])
self.assertEqual(ctx.exception.kind, 'format')
def test_rejects_short_form_ipv4(self):
for entry in ('2:149.154', '2:1.2.3.4.5', '2:999.1.1.1', '2:abc'):
with self.subTest(entry=entry), self.assertRaises(ValueError) as ctx:
parse_dc_ip_list([entry])
self.assertEqual(ctx.exception.kind, 'invalid')
def test_rejects_non_numeric_dc(self):
with self.assertRaises(ValueError):
parse_dc_ip_list(['x:1.2.3.4'])
class CoerceDomainListTest(unittest.TestCase):
def test_splits_on_common_separators(self):
self.assertEqual(
coerce_domain_list('a.com, b.com; c.com d.com'),
['a.com', 'b.com', 'c.com', 'd.com'],
)
def test_deduplicates_case_insensitively_keeping_first(self):
self.assertEqual(coerce_domain_list(['A.com', 'a.com']), ['A.com'])
def test_flattens_sequences_and_skips_non_strings(self):
self.assertEqual(coerce_domain_list(['a.com b.com', 5, None]), ['a.com', 'b.com'])
def test_returns_empty_for_unsupported_types(self):
self.assertEqual(coerce_domain_list(None), [])
self.assertEqual(coerce_domain_list(42), [])
class DomainValidationTest(unittest.TestCase):
def test_accepts_ordinary_domains(self):
for domain in ('example.com', 'a-b.co.uk', 'x.io'):
self.assertTrue(_is_valid_domain(domain), domain)
def test_rejects_malformed_domains(self):
for domain in ('', 'nodot', '.leading.com', 'trailing.com.',
'-bad.com', 'bad-.com', 'a..com', 'a.1',
'a.' + 'b' * 64, 'a' * 250 + '.com'):
self.assertFalse(_is_valid_domain(domain), domain)
def test_normalize_lowercases_dedupes_and_drops_invalid(self):
self.assertEqual(
_normalize_domain_pool(['B.com ', 'b.com', 'nodot', 'a.com']),
['b.com', 'a.com'],
)
class DefaultDomainsTest(unittest.TestCase):
def test_decoded_defaults_are_valid_domains(self):
self.assertTrue(CFPROXY_DEFAULT_DOMAINS)
for domain in CFPROXY_DEFAULT_DOMAINS:
self.assertTrue(_is_valid_domain(domain), domain)
def test_decoded_defaults_are_unique(self):
self.assertEqual(
len(set(CFPROXY_DEFAULT_DOMAINS)), len(CFPROXY_DEFAULT_DOMAINS)
)
if __name__ == '__main__':
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
import hashlib
import hmac
import os
import struct
import time
import unittest
from proxy.fake_tls import (
CLIENT_RANDOM_LEN,
CLIENT_RANDOM_OFFSET,
SESSION_ID_LEN,
SESSION_ID_OFFSET,
TLS_APPDATA_MAX,
TLS_RECORD_HANDSHAKE,
build_server_hello,
verify_client_hello,
wrap_tls_record,
)
SECRET = bytes.fromhex('00112233445566778899aabbccddeeff')
def _client_hello(secret: bytes = SECRET, timestamp: int = None,
session_id: bytes = None) -> bytes:
if timestamp is None:
timestamp = int(time.time())
if session_id is None:
session_id = os.urandom(SESSION_ID_LEN)
body = bytearray(517)
body[0] = TLS_RECORD_HANDSHAKE
body[1:3] = b'\x03\x01'
struct.pack_into('>H', body, 3, len(body) - 5)
body[5] = 0x01
body[43] = 0x20
body[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN] = session_id
digest = hmac.new(secret, bytes(body), hashlib.sha256).digest()
client_random = bytearray(digest[:CLIENT_RANDOM_LEN])
ts_bytes = struct.pack('<I', timestamp)
for i in range(4):
client_random[28 + i] = digest[28 + i] ^ ts_bytes[i]
body[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = client_random
return bytes(body)
class VerifyClientHelloTest(unittest.TestCase):
def test_accepts_well_formed_hello(self):
session_id = os.urandom(SESSION_ID_LEN)
now = int(time.time())
result = verify_client_hello(_client_hello(timestamp=now,
session_id=session_id), SECRET)
self.assertIsNotNone(result)
client_random, got_session_id, ts = result
self.assertEqual(len(client_random), CLIENT_RANDOM_LEN)
self.assertEqual(got_session_id, session_id)
self.assertEqual(ts, now)
def test_rejects_wrong_secret(self):
other = bytes.fromhex('ffeeddccbbaa99887766554433221100')
self.assertIsNone(verify_client_hello(_client_hello(), other))
def test_rejects_stale_timestamp(self):
stale = int(time.time()) - 3600
self.assertIsNone(verify_client_hello(_client_hello(timestamp=stale), SECRET))
def test_rejects_tampered_body(self):
hello = bytearray(_client_hello())
hello[300] ^= 0xFF
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
def test_rejects_short_and_non_handshake_records(self):
self.assertIsNone(verify_client_hello(b'\x16\x03\x01\x00\x10', SECRET))
hello = bytearray(_client_hello())
hello[0] = 0x17
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
hello = bytearray(_client_hello())
hello[5] = 0x02
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
class BuildServerHelloTest(unittest.TestCase):
def test_echoes_session_id_and_binds_client_random(self):
session_id = os.urandom(SESSION_ID_LEN)
client_random = os.urandom(CLIENT_RANDOM_LEN)
response = build_server_hello(SECRET, client_random, session_id)
self.assertEqual(response[0], TLS_RECORD_HANDSHAKE)
self.assertEqual(
response[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN],
session_id,
)
zeroed = bytearray(response)
zeroed[11:11 + 32] = b'\x00' * 32
expected = hmac.new(SECRET, client_random + bytes(zeroed),
hashlib.sha256).digest()
self.assertEqual(response[11:11 + 32], expected)
def test_padding_length_varies_between_calls(self):
sizes = {
len(build_server_hello(SECRET, os.urandom(32), os.urandom(32)))
for _ in range(20)
}
self.assertGreater(len(sizes), 1)
class WrapTlsRecordTest(unittest.TestCase):
def test_short_payload_becomes_one_record(self):
wrapped = wrap_tls_record(b'hello')
self.assertEqual(wrapped, b'\x17\x03\x03\x00\x05hello')
def test_long_payload_is_chunked_to_the_record_limit(self):
payload = os.urandom(TLS_APPDATA_MAX + 100)
wrapped = wrap_tls_record(payload)
offset = 0
chunks = []
while offset < len(wrapped):
length = struct.unpack('>H', wrapped[offset + 3:offset + 5])[0]
self.assertLessEqual(length, TLS_APPDATA_MAX)
chunks.append(wrapped[offset + 5:offset + 5 + length])
offset += 5 + length
self.assertEqual(len(chunks), 2)
self.assertEqual(b''.join(chunks), payload)
def test_empty_payload_produces_no_records(self):
self.assertEqual(wrap_tls_record(b''), b'')
if __name__ == '__main__':
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
"""H2 belongs to CF fallback, never to the direct route."""
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge, tg_ws_proxy
from proxy.config import proxy_config
from proxy.utils import PROTO_TAG_INTERMEDIATE
class H2RoutingTest(unittest.IsolatedAsyncioTestCase):
async def test_direct_pool_hit_never_opens_h2(self):
reader = Mock()
writer = Mock()
writer.get_extra_info.return_value = None
writer.wait_closed = AsyncMock()
ws = Mock(send=AsyncMock())
pool = SimpleNamespace(open=AsyncMock())
with patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.51'}), \
patch.object(proxy_config, 'force_test_dc', False), \
patch.object(tg_ws_proxy, 'cf_h2_pool', pool), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
2, True, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=ws)), \
patch.object(tg_ws_proxy, 'bridge_ws_reencrypt', AsyncMock()) as direct, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock()) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
direct.assert_awaited_once()
fallback.assert_not_awaited()
pool.open.assert_not_awaited()
async def test_pool_miss_goes_straight_to_fallback_including_test_dcs(self):
for dc, forced, expected_test in [(2, False, False),
(10002, False, True),
(2, True, True)]:
with self.subTest(dc=dc, forced=forced):
reader = Mock()
writer = Mock(wait_closed=AsyncMock())
writer.get_extra_info.return_value = None
with patch.object(proxy_config, 'force_test_dc', forced), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
dc, False, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=None)) as get, \
patch('proxy.raw_websocket.RawWebSocket.connect', AsyncMock()) as connect, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock(return_value=True)) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
get.assert_awaited_once_with(2, False, is_test_dc=expected_test)
fallback.assert_awaited_once()
self.assertEqual(fallback.call_args.args[4:7], (2, expected_test, False))
connect.assert_not_awaited()
async def test_cf_fallback_order_and_route_guards(self):
cases = [
# media, test DC, CF enabled, Worker succeeds, H2 opens, expected
(True, False, True, False, True, ['h2', 'bridge']),
(True, False, True, False, False, ['h2', 'ws']),
(False, False, True, False, True, ['ws']),
(True, True, True, False, True, ['tcp']),
(True, False, False, False, True, ['tcp']),
(True, False, True, True, True, ['worker']),
]
for media, test_dc, cf, worker, opens, expected in cases:
with self.subTest(expected=expected, media=media, test_dc=test_dc, cf=cf):
calls = []
async def open_h2(*args):
calls.append('h2')
return object() if opens else None
def route(name):
async def run(*args, **kwargs):
calls.append(name)
return True
return run
pool = SimpleNamespace(open=AsyncMock(side_effect=open_h2))
with patch.object(proxy_config, 'fallback_cfproxy', cf), \
patch.object(proxy_config, 'cfproxy_worker_domains',
['worker.example'] if worker else []), \
patch.object(bridge, 'bridge_h2', side_effect=route('bridge')), \
patch.object(bridge, '_cfproxy_fallback', side_effect=route('ws')), \
patch.object(bridge, '_cfproxy_worker_fallback', side_effect=route('worker')), \
patch.object(bridge, '_tcp_fallback', side_effect=route('tcp')):
result = await bridge.do_fallback(
Mock(), Mock(), b'', 'test', 2, test_dc, media, '', Mock(),
h2_pool=pool, proto_tag=PROTO_TAG_INTERMEDIATE)
self.assertTrue(result)
self.assertEqual(calls, expected)
+127
View File
@@ -0,0 +1,127 @@
"""Default/CLI/GUI configuration must select the same effective H2 route."""
import asyncio
import contextlib
import io
import json
import logging
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from proxy.config import ProxyConfig
from proxy import tg_ws_proxy
from utils import tray_common
from utils.default_config import default_tray_config
from ui.settings import prepare_settings
class H2SettingsTest(unittest.TestCase):
def test_cli_default_opt_out_and_existing_launch_script(self):
cases = [([], True), (['--no-h2'], False),
(['--cfproxy-h2-media'], True), (['--no-cfproxy'], False),
(['--no-secure'], False), (['--force-test-dc'], False)]
for flags, enabled in cases:
with self.subTest(flags=flags):
config = ProxyConfig()
observed = []
async def run():
observed.append(config.h2_enabled)
root = logging.getLogger()
previous_handlers, previous_level = root.handlers[:], root.level
try:
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, '_run', run), \
patch('sys.argv', ['proxy', '--secret', 'ab' * 16] + flags):
tg_ws_proxy.main()
self.assertEqual(observed, [enabled])
finally:
for handler in root.handlers[:]:
if handler not in previous_handlers:
root.removeHandler(handler)
handler.close()
root.setLevel(previous_level)
def test_help_advertises_opt_out_only(self):
output = io.StringIO()
with patch('sys.argv', ['proxy', '--help']), contextlib.redirect_stdout(output):
with self.assertRaises(SystemExit) as raised:
tg_ws_proxy.main()
self.assertEqual(raised.exception.code, 0)
self.assertIn('--no-h2', output.getvalue())
self.assertNotIn('--cfproxy-h2-media', output.getvalue())
def test_saved_setting_migration_and_runtime_restart(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'config.json'
path.write_text(json.dumps({'port': 1444}), encoding='utf-8')
with patch.object(tray_common, 'CONFIG_FILE', path), \
patch.object(tray_common, 'ensure_dirs'), \
patch.object(tray_common, '_apply_ui_language'):
config = tray_common.load_config()
self.assertTrue(config['h2'])
change = prepare_settings(config, {'h2': False}, default_tray_config())
self.assertTrue(change.requires_restart)
tray_common.save_config(change.config)
saved = tray_common.load_config()
self.assertFalse(saved['h2'])
runtime = ProxyConfig()
with patch.object(tray_common, 'proxy_config', runtime):
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertFalse(runtime.h2_enabled)
saved['h2'] = True
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertTrue(runtime.h2_enabled)
for key, value in [('cfproxy', False), ('no_secure', True),
('force_test_dc', True)]:
self.assertTrue(tray_common.apply_proxy_config({**saved, key: value}))
self.assertFalse(runtime.h2_enabled)
self.assertTrue(runtime.cfproxy_h2_media)
class H2ServerLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def test_stop_and_cancellation_close_default_h2_and_listener_tasks(self):
for cancel in (False, True):
with self.subTest(cancel=cancel):
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0)
stop = asyncio.Event()
before = asyncio.all_tasks()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()):
server = asyncio.create_task(tg_ws_proxy._run(stop))
writer = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
pool = tg_ws_proxy.cf_h2_pool
self.assertIsNotNone(pool)
port = tg_ws_proxy._server_instance.sockets[0].getsockname()[1]
_, writer = await asyncio.open_connection('127.0.0.1', port)
if cancel:
server.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(server, 2)
else:
stop.set()
await asyncio.wait_for(server, 2)
self.assertTrue(pool.closed)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
self.assertIsNone(tg_ws_proxy._server_instance)
self.assertFalse(tg_ws_proxy._client_tasks)
self.assertFalse(asyncio.all_tasks() - before)
finally:
server.cancel()
await asyncio.gather(server, return_exceptions=True)
if writer is not None:
writer.close()
await writer.wait_closed()
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
import asyncio
import time
import unittest
from collections import deque
from types import SimpleNamespace
from unittest import mock
from proxy.config import proxy_config
from proxy.pool import _WsPool
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
from proxy.utils import ws_domains
class _StopRotation(Exception):
pass
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = mock.Mock(transport=transport, drain=mock.AsyncMock(),
wait_closed=mock.AsyncMock())
return RawWebSocket(asyncio.StreamReader(), writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_warmup_preserves_media_ws_with_or_without_h2(self):
for cf, secure, opted_out, media in [(True, True, False, True),
(False, True, False, True),
(True, False, False, True),
(True, True, True, True)]:
with self.subTest(cf=cf, secure=secure, opted_out=opted_out), \
mock.patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.220'}), \
mock.patch.object(proxy_config, 'fallback_cfproxy', cf), \
mock.patch.object(proxy_config, 'disable_secure', not secure), \
mock.patch.object(proxy_config, 'cfproxy_h2_media', not opted_out), \
mock.patch.object(proxy_config, 'force_test_dc', False):
pool = _WsPool()
with mock.patch.object(pool, '_schedule_refill') as refill:
await pool.warmup()
keys = [call.args[0] for call in refill.call_args_list]
self.assertIn((2, False, False), keys)
self.assertEqual((2, True, False) in keys, media)
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
async def stop_after_one_iteration(_delay):
raise _StopRotation
with mock.patch.object(proxy_config, 'pool_size', 4):
with mock.patch(
'proxy.pool.asyncio.sleep',
side_effect=stop_after_one_iteration):
with mock.patch.object(
pool, '_schedule_refill') as schedule_refill:
with self.assertRaises(_StopRotation):
await pool._rotate(
key, '149.154.167.220', ['example.com'])
schedule_refill.assert_called_once_with(
key, '149.154.167.220', ['example.com'])
class WsPoolTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.pool = _WsPool()
self.key = (2, False, False)
self.pool.WS_POOL_CHECK_INTERVAL = .01
for name, value in [('dc_redirects', {2: '192.0.2.1'}),
('pool_size', 1), ('force_test_dc', False)]:
patcher = mock.patch.object(proxy_config, name, value)
patcher.start()
self.addCleanup(patcher.stop)
self.addAsyncCleanup(self.pool.close)
async def wait_for(self, predicate):
async def wait():
while not predicate():
await asyncio.sleep(.001)
await asyncio.wait_for(wait(), 1)
async def test_regular_sessions_never_use_media_domain(self):
for dc in (2, 4):
with self.subTest(dc=dc), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=WsHandshakeError(302, 'redirect')) as connect:
self.assertIsNone(await self.pool._connect_one('192.0.2.1', ws_domains(dc, False)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
[f'kws{dc}.web.telegram.org'] * 2)
async def test_media_tries_regular_domain_after_both_modes_fail(self):
for error in (asyncio.TimeoutError(), ConnectionResetError(),
WsHandshakeError(302, 'redirect'), WsHandshakeError(500, 'error')):
with self.subTest(error=type(error)), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=[error, error, _open_ws()]) as connect:
self.assertIsNotNone(await self.pool._connect_one('192.0.2.1', ws_domains(2, True)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
['kws2-1.web.telegram.org'] * 2 + ['kws2.web.telegram.org'])
async def test_concurrent_preference_change_does_not_skip_fronting(self):
ws = _open_ws()
async def connect(*args, **kwargs):
if kwargs['sni'] is None:
self.pool.try_fronting_first = True
raise asyncio.TimeoutError()
return ws
with mock.patch('proxy.pool.RawWebSocket.connect', side_effect=connect) as dial:
self.assertIs(await self.pool._connect_one('192.0.2.1', ws_domains(2, False)), ws)
self.assertEqual(dial.await_count, 2)
async def test_miss_returns_without_waiting_and_starts_only_one_refill(self):
started = asyncio.Event()
async def connect(*args):
started.set()
await asyncio.Future()
with mock.patch.object(self.pool, '_connect_one', side_effect=connect) as dial:
for _ in range(10):
self.assertIsNone(await self.pool.get(2, False))
await asyncio.wait_for(started.wait(), 1)
self.assertEqual(dial.await_count, 1)
await self.pool.close()
async def test_empty_pool_recovers_in_background_after_backoff(self):
ws = _open_ws()
self.pool.REFILL_BACKOFF_INITIAL = .02
with mock.patch.object(self.pool, '_connect_one',
side_effect=[None, ws]) as dial:
self.assertIsNone(await self.pool.get(2, False))
await self.wait_for(lambda: self.key in self.pool._refill_after)
self.assertEqual(dial.await_count, 1)
# No new client requests are needed to resume refilling.
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertEqual(dial.await_count, 2)
self.assertNotIn(self.key, self.pool._refill_failures)
await self.pool.close()
async def test_ready_connection_is_available_before_slower_attempt(self):
ready = _open_ws()
slow_started = asyncio.Event()
calls = 0
async def connect(*args):
nonlocal calls
calls += 1
if calls == 1:
slow_started.set()
await asyncio.Future()
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
await self.pool.get(2, False)
await asyncio.wait_for(slow_started.wait(), 1)
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertIs(await self.pool.get(2, False), ready)
await self.pool.close()
async def test_stale_connections_are_skipped(self):
for state in ('expired', 'eof', 'exception', 'closed', 'closing'):
with self.subTest(state=state):
stale, good = _open_ws(), _open_ws()
created = time.monotonic()
if state == 'expired':
created -= self.pool.WS_POOL_MAX_AGE
elif state == 'eof':
stale.reader.feed_eof()
elif state == 'exception':
stale.reader.set_exception(ConnectionResetError())
elif state == 'closed':
stale._closed = True
else:
stale.writer.transport.is_closing = lambda: True
self.pool._idle[self.key] = deque([(stale, created), (good, time.monotonic())])
with mock.patch.object(self.pool, '_schedule_refill'):
self.assertIs(await self.pool.get(2, False), good)
await asyncio.sleep(0)
async def test_hit_does_not_reset_failed_refill_backoff(self):
self.pool._idle[self.key] = deque([(_open_ws(), time.monotonic())])
self.pool._refill_failures[self.key] = 3
self.pool._refill_after[self.key] = time.monotonic() + 60
with mock.patch.object(self.pool, '_schedule_rotation'):
self.assertIsNotNone(await self.pool.get(2, False))
self.assertEqual(self.pool._refill_failures[self.key], 3)
self.assertFalse(self.pool._refilling)
async def test_zero_size_and_unconfigured_dc_do_not_connect(self):
with mock.patch.object(self.pool, '_connect_one') as dial:
self.assertIsNone(await self.pool.get(4, False))
with mock.patch.object(proxy_config, 'pool_size', 0):
await self.pool.warmup()
self.assertIsNone(await self.pool.get(2, False))
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
dial.assert_not_called()
async def test_test_dc_has_separate_pool_and_path(self):
prod = _open_ws()
self.pool._idle[self.key] = deque([(prod, time.monotonic())])
with mock.patch.object(self.pool, '_connect_one', return_value=_open_ws()) as dial:
self.assertIsNone(await self.pool.get(2, False, is_test_dc=True))
test_key = (2, False, True)
await self.wait_for(lambda: bool(self.pool._idle.get(test_key)))
dial.assert_awaited_once_with('192.0.2.1', ['kws2.web.telegram.org'], '/apiws_test')
self.assertIs(self.pool._idle[self.key][0][0], prod)
await self.pool.close()
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
ready = _open_ws()
idle = _open_ws()
started = asyncio.Event()
self.pool._idle[self.key] = deque([(idle, time.monotonic())])
async def connect(*args):
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
# Completion raced with shutdown; it must not leak into the pool.
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
self.pool._schedule_refill(self.key, '192.0.2.1', ws_domains(2, False))
await asyncio.wait_for(started.wait(), 1)
await self.pool.close()
self.assertFalse(self.pool._idle)
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
ready.writer.close.assert_called_once()
idle.writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
def _raw_frame(opcode, data, fin=True):
b0 = (0x80 if fin else 0x00) | opcode
n = len(data)
if n < 126:
return bytes([b0, n]) + data
if n < 65536:
return bytes([b0, 126]) + n.to_bytes(2, 'big') + data
return bytes([b0, 127]) + n.to_bytes(8, 'big') + data
class _NullWriter:
def write(self, data):
pass
async def drain(self):
pass
def _recv(chunks, cls=RawWebSocket):
async def _run():
reader = asyncio.StreamReader()
for chunk in chunks:
reader.feed_data(chunk)
reader.feed_eof()
ws = cls(reader, _NullWriter())
return ws, await ws.recv()
return asyncio.run(_run())
class XorMaskTest(unittest.TestCase):
def test_roundtrip(self):
data = bytes(range(256)) * 3
mask = b'\x01\x02\x03\x04'
self.assertEqual(_xor_mask(_xor_mask(data, mask), mask), data)
def test_empty_payload(self):
self.assertEqual(_xor_mask(b'', b'\x01\x02\x03\x04'), b'')
class BuildFrameTest(unittest.TestCase):
def test_short_unmasked_frame(self):
self.assertEqual(
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
b'\x82\x03abc',
)
def test_extended_length_selects_16bit_header(self):
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'x' * 200)
self.assertEqual(frame[:2], b'\x82\x7e')
self.assertEqual(int.from_bytes(frame[2:4], 'big'), 200)
def test_masked_frame_sets_mask_bit_and_is_reversible(self):
payload = b'payload'
frame = RawWebSocket._build_frame(
RawWebSocket.OP_BINARY, payload, mask=True)
self.assertTrue(frame[1] & 0x80)
self.assertEqual(_xor_mask(frame[6:], frame[2:6]), payload)
class RecvTest(unittest.TestCase):
def test_returns_unfragmented_message(self):
_, msg = _recv([_raw_frame(RawWebSocket.OP_BINARY, b'hello')])
self.assertEqual(msg, b'hello')
def test_reassembles_fragmented_message(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', False),
_raw_frame(RawWebSocket.OP_CONT, b'CCC', True),
])
self.assertEqual(msg, b'AAABBBCCC')
def test_control_frame_between_fragments_is_skipped(self):
_, msg = _recv([
_raw_frame(RawWebSocket.OP_BINARY, b'AAA', False),
_raw_frame(RawWebSocket.OP_PONG, b''),
_raw_frame(RawWebSocket.OP_CONT, b'BBB', True),
])
self.assertEqual(msg, b'AAABBB')
def test_close_frame_returns_none(self):
ws, msg = _recv([_raw_frame(RawWebSocket.OP_CLOSE, b'\x03\xe8')])
self.assertIsNone(msg)
self.assertTrue(ws._closed)
def test_oversized_frame_is_rejected_before_reading_payload(self):
header = bytes([0x82, 127]) + (1 << 40).to_bytes(8, 'big')
with self.assertRaises(ConnectionError):
_recv([header])
def test_reassembled_message_exceeding_limit_is_rejected(self):
class _Capped(RawWebSocket):
__slots__ = ()
MAX_MESSAGE_LEN = 1500
chunk = b'x' * 1024
with self.assertRaises(ConnectionError):
_recv([
_raw_frame(RawWebSocket.OP_BINARY, chunk, False),
_raw_frame(RawWebSocket.OP_CONT, chunk, False),
], cls=_Capped)
class ParseCloseTest(unittest.TestCase):
def test_known_code_gets_name(self):
code, reason = RawWebSocket._parse_close(b'\x03\xe8bye')
self.assertEqual(code, 1000)
self.assertIn('normal', reason)
def test_empty_payload(self):
self.assertEqual(RawWebSocket._parse_close(b''), (None, ''))
class HandshakeErrorTest(unittest.TestCase):
def test_redirect_status_codes(self):
for code in (301, 302, 303, 307, 308):
self.assertTrue(WsHandshakeError(code, '').is_redirect)
for code in (0, 200, 429, 502):
self.assertFalse(WsHandshakeError(code, '').is_redirect)
class HandshakeCleanupTest(unittest.IsolatedAsyncioTestCase):
async def test_cancelling_pending_upgrade_closes_socket(self):
reader = asyncio.StreamReader()
writer = Mock(drain=AsyncMock())
started = asyncio.Event()
async def drain():
started.set()
writer.drain.side_effect = drain
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
task = asyncio.create_task(RawWebSocket.connect('192.0.2.1', 'example.org'))
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
writer.close.assert_called_once()
async def test_failed_upgrade_closes_socket(self):
for error in (ConnectionResetError(), asyncio.TimeoutError()):
with self.subTest(error=type(error)):
reader = Mock(readline=AsyncMock(side_effect=error))
writer = Mock(drain=AsyncMock())
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
with self.assertRaises(type(error)):
await RawWebSocket.connect('192.0.2.1', 'example.org')
writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge
class TcpFallbackTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
bridge.reset_tcp_backoff()
self.addCleanup(bridge.reset_tcp_backoff)
self.key = ('192.0.2.1', 443)
self.remote = Mock(drain=AsyncMock(), wait_closed=AsyncMock())
async def fallback(self, dst='192.0.2.1', port=443):
return await bridge._tcp_fallback(
Mock(), Mock(), dst, port, b'init', 'test', Mock())
async def test_failure_backoff_doubles_caps_and_skips_new_connections(self):
with patch.object(bridge.asyncio, 'open_connection',
AsyncMock(side_effect=asyncio.TimeoutError())) as connect:
for delay in (30, 60, 120, 240, 480, 960, 1920, 3600, 3600):
bridge._tcp_retry_after[self.key] = 0
start = time.monotonic()
self.assertFalse(await self.fallback())
remaining = bridge._tcp_retry_after[self.key] - start
self.assertGreaterEqual(remaining, delay - .001)
self.assertLess(remaining, delay + 1)
count = connect.await_count
self.assertFalse(await self.fallback())
self.assertEqual(connect.await_count, count)
self.assertFalse(bridge._tcp_connecting)
async def test_other_ips_and_ports_are_not_blocked(self):
bridge._tcp_retry_after[self.key] = time.monotonic() + 60
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback('192.0.2.2'))
self.assertTrue(await self.fallback(port=80))
self.assertEqual(connect.await_count, 2)
async def test_success_clears_backoff_and_allows_following_sessions(self):
bridge._tcp_failures[self.key] = 5
bridge._tcp_retry_after[self.key] = 0
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback())
self.assertNotIn(self.key, bridge._tcp_failures)
self.assertNotIn(self.key, bridge._tcp_retry_after)
self.assertTrue(await self.fallback())
self.assertEqual(connect.await_count, 2)
async def test_concurrent_requests_only_start_one_connection_attempt(self):
started = asyncio.Event()
release = asyncio.Event()
async def connect(*args):
started.set()
await release.wait()
raise ConnectionRefusedError()
with patch.object(bridge.asyncio, 'open_connection', side_effect=connect) as dial:
first = asyncio.create_task(self.fallback())
try:
await asyncio.wait_for(started.wait(), 1)
results = await asyncio.gather(*(self.fallback() for _ in range(8)))
self.assertEqual(results, [False] * 8)
self.assertEqual(dial.await_count, 1)
release.set()
self.assertFalse(await first)
finally:
first.cancel()
await asyncio.gather(first, return_exceptions=True)
async def test_init_failure_closes_socket_and_enters_backoff(self):
self.remote.drain.side_effect = ConnectionResetError()
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))), \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()) as forward:
self.assertFalse(await self.fallback())
self.remote.close.assert_called_once()
forward.assert_not_awaited()
self.assertIn(self.key, bridge._tcp_retry_after)
async def test_cancellation_releases_attempt_without_marking_ip_blocked(self):
started = asyncio.Event()
async def drain():
started.set()
await asyncio.Future()
self.remote.drain.side_effect = drain
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))):
task = asyncio.create_task(self.fallback())
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.remote.close.assert_called_once()
self.assertFalse(bridge._tcp_connecting)
self.assertFalse(bridge._tcp_failures)
if __name__ == '__main__':
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
import unittest
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
class ParseVersionTupleTest(unittest.TestCase):
def test_plain_and_prefixed_versions(self):
self.assertEqual(_parse_version_tuple('1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('v1.9.1'), (1, 9, 1))
self.assertEqual(_parse_version_tuple(' V2.0 '), (2, 0))
def test_trailing_suffixes_are_truncated_to_digits(self):
self.assertEqual(_parse_version_tuple('1.9.1rc2'), (1, 9, 1))
self.assertEqual(_parse_version_tuple('1.9.1-beta'), (1, 9, 1))
def test_empty_and_non_numeric_segments(self):
self.assertEqual(_parse_version_tuple(''), (0,))
self.assertEqual(_parse_version_tuple(None), (0,))
self.assertEqual(_parse_version_tuple('1.x.3'), (1, 0, 3))
class VersionGtTest(unittest.TestCase):
def test_newer_versions(self):
self.assertTrue(_version_gt('1.9.2', '1.9.1'))
self.assertTrue(_version_gt('1.10.0', '1.9.9'))
self.assertTrue(_version_gt('2.0', '1.9.9'))
def test_equal_and_older_versions(self):
self.assertFalse(_version_gt('1.9.1', '1.9.1'))
self.assertFalse(_version_gt('1.9.1', '1.9.2'))
self.assertFalse(_version_gt('1.9', '1.9.0'))
def test_shorter_version_is_padded_with_zeros(self):
self.assertTrue(_version_gt('1.9.1', '1.9'))
self.assertFalse(_version_gt('1.9', '1.9.1'))
class ExtractAssetsTest(unittest.TestCase):
def test_keeps_name_url_and_digest(self):
data = {'assets': [{
'name': 'TgWsProxy_windows.exe',
'browser_download_url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}]}
self.assertEqual(_extract_assets(data), [{
'name': 'TgWsProxy_windows.exe',
'url': 'https://example.invalid/a.exe',
'digest': 'sha256:abc',
}])
def test_drops_entries_without_name_or_url(self):
data = {'assets': [
{'name': 'a.exe'},
{'browser_download_url': 'https://example.invalid/b.exe'},
]}
self.assertEqual(_extract_assets(data), [])
def test_missing_digest_becomes_empty_string(self):
data = {'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe',
}]}
self.assertEqual(_extract_assets(data)[0]['digest'], '')
def test_empty_input(self):
self.assertEqual(_extract_assets(None), [])
self.assertEqual(_extract_assets({}), [])
if __name__ == '__main__':
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import queue
import threading
log = logging.getLogger("tg-ws-tray")
class BackgroundTask:
def __init__(self, owner):
self.owner = owner
self.running = False
self.closed = False
self.pending = None
owner.bind("<Destroy>", self._on_destroy, add="+")
def _on_destroy(self, event):
if event.widget is self.owner:
self.closed = True
if self.pending is not None:
self.owner.after_cancel(self.pending)
self.pending = None
def start(self, work, complete, finish):
if self.running or self.closed:
return
self.running = True
results = queue.Queue()
def run():
try:
results.put((True, work()))
except Exception:
log.exception("Connectivity test failed")
results.put((False, None))
def poll():
self.pending = None
if self.closed:
return
try:
success, result = results.get_nowait()
except queue.Empty:
self.pending = self.owner.after(50, poll)
return
self.running = False
try:
if success:
complete(result)
finally:
if not self.closed:
finish()
threading.Thread(target=run, daemon=True, name="connectivity-test").start()
self.pending = self.owner.after(50, poll)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import base64
import os
import socket as _socket
from contextlib import nullcontext
from tkinter import messagebox
from typing import Any
from proxy.utils import create_ssl_context
from ui.i18n import t
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
ctx = create_ssl_context() if secure else None
port = 443 if secure else 80
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, port), timeout=5) as raw:
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
if secure else nullcontext(raw))
with connection as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or t("connectivity.no_response")
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = t("connectivity.timeout")
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> 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, secure=secure)
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> 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, secure=secure)
def run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '', *, parent: Any) -> None:
ok = [dc for dc, v in results.items() if v is True]
total = len(_CFPROXY_TEST_DCS)
if auto_mode:
if domain:
title = t("connectivity.available", title=title_base)
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
else:
title = t("connectivity.unavailable", title=title_base)
msg = unavailable_message
else:
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
title = t("connectivity.all_ok", title=title_base)
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
elif not ok:
title = t("connectivity.unavailable", title=title_base)
errors = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.none_ok", domain=domain, errors=errors)
else:
title = t("connectivity.partial", title=title_base)
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
messagebox.showinfo(title, msg, parent=parent)
def show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC', *, parent: Any) -> None:
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
elif not ok:
all_ok = False
blocks.append(t("connectivity.multi_fail", domain=domain))
else:
all_ok = False
any_ok = True
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
blocks.append(
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
)
if all_ok:
title = t("connectivity.all_ok", title=title_base)
elif any_ok:
title = t("connectivity.partial", title=title_base)
else:
title = t("connectivity.unavailable", title=title_base)
msg = "\n\n".join(blocks)
messagebox.showinfo(title, msg, parent=parent)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_tooltip_to_widgets
def create_entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = {
"font": (theme.ui_font_family, 13), "corner_radius": radius,
"fg_color": theme.bg, "border_color": theme.field_border,
"border_width": 1, "text_color": theme.text_primary,
}
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def create_checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def create_label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def create_labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = create_label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(master=parent, value=str(value))
ent = create_entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def create_config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
create_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
def create_combobox(ctk, parent, theme, *, variable, values, command=None):
return ctk.CTkComboBox(
parent, values=values, variable=variable, command=command,
height=32, font=(theme.ui_font_family, 12),
text_color=theme.text_primary, fg_color=theme.bg,
border_color=theme.field_border, button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8, state="readonly",
)
+10 -13
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import tkinter as tk import customtkinter as ctk
from typing import Any, List, Optional from typing import Any, List, Optional
@@ -18,7 +18,7 @@ class CtkTooltip:
self.delay_ms = delay_ms self.delay_ms = delay_ms
self.wraplength = wraplength self.wraplength = wraplength
self._after_id: Optional[str] = None self._after_id: Optional[str] = None
self._tip: Optional[tk.Toplevel] = None self._tip: Optional[ctk.CTkToplevel] = None
widget.bind("<Enter>", self._schedule, add="+") widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._hide, add="+") widget.bind("<Leave>", self._hide, add="+")
widget.bind("<Button>", self._hide, add="+") widget.bind("<Button>", self._hide, add="+")
@@ -48,27 +48,24 @@ class CtkTooltip:
except Exception: except Exception:
return return
tw = tk.Toplevel(self.widget.winfo_toplevel()) tw = ctk.CTkToplevel(self.widget.winfo_toplevel())
tw.wm_overrideredirect(True) tw.wm_overrideredirect(True)
try: try:
tw.wm_attributes("-topmost", True) tw.wm_attributes("-topmost", True)
except Exception: except Exception:
pass pass
tw.configure(bg="#2b2b2b") tw.configure(fg_color="#2b2b2b")
lbl = tk.Label( lbl = ctk.CTkLabel(
tw, tw,
text=self.text, text=self.text,
justify="left", justify="left",
wraplength=self.wraplength, wraplength=self.wraplength,
background="#2b2b2b", fg_color="#2b2b2b",
foreground="#f0f0f0", text_color="#f0f0f0",
relief="flat", corner_radius=0,
borderwidth=0, font=("Segoe UI", 14) if _is_windows() else None,
padx=10,
pady=8,
font=("Segoe UI", 10) if _is_windows() else None,
) )
lbl.pack() lbl.pack(padx=10, pady=8)
x = self.widget.winfo_rootx() + 12 x = self.widget.winfo_rootx() + 12
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
tw.wm_geometry(f"+{x}+{y}") tw.wm_geometry(f"+{x}+{y}")
+29 -848
View File
@@ -1,323 +1,17 @@
from __future__ import annotations from __future__ import annotations
import logging from typing import Any, Callable, Tuple
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list from proxy import get_link_host
from proxy.balancer import balancer from ui.ctk_controls import create_checkbox
from utils.update_check import RELEASES_PAGE_URL, get_status from ui.ctk_theme import FIRST_RUN_FRAME_PAD, CtkTheme, main_content_frame
from ui.ctk_tooltip import attach_ctk_tooltip
from ui.i18n import t
from ui.ctk_theme import ( from ui.settings_form import (
FIRST_RUN_FRAME_PAD, TrayConfigFormWidgets as TrayConfigFormWidgets,
CtkTheme, install_tray_config_form as install_tray_config_form,
main_content_frame, validate_config_form as validate_config_form,
) )
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
log = logging.getLogger('tg-mtproto-proxy')
_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 = "Закрыть окно без сохранения изменений"
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_connectivity_test(cases: list) -> dict:
import base64
import ssl
import socket as _socket
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or "нет ответа"
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = "таймаут"
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _run_cfproxy_connectivity_test(domain: str) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases)
def _run_cfworker_connectivity_test(domain: str) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases)
def _run_cfproxy_multi_test(domains: list) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
def _run_cfworker_multi_test(domains: list) -> dict:
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
def _run_cfproxy_auto_test(domains: list) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def _show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '') -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
ok = [dc for dc, v in results.items() if v is True]
if auto_mode:
if domain:
title = f"{title_base}: доступен"
msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
else:
title = f"{title_base}: недоступен"
msg = unavailable_message
else:
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == len(_CFPROXY_TEST_DCS):
title = f"{title_base}: всё работает"
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
elif not ok:
title = f"{title_base}: недоступен"
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
else:
title = f"{title_base}: частично работает"
msg = (
f"Домен: {domain}\n\n"
f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n"
f"\u2717 Недоступны:\n"
+ "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC') -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(f"\u2713 {domain}: все {total} серверов доступны")
elif not ok:
all_ok = False
blocks.append(f"\u2717 {domain}: недоступен")
else:
all_ok = False
any_ok = True
blocks.append(
f"~ {domain}: работают "
f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; "
f"недоступны "
f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}"
)
if all_ok:
title = f"{title_base}: всё работает"
elif any_ok:
title = f"{title_base}: частично работает"
else:
title = f"{title_base}: недоступен"
msg = "\n\n".join(blocks)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
_INNER_W = 396
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def _checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = _label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(value=str(value))
ent = _entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def tray_settings_scroll_and_footer( def tray_settings_scroll_and_footer(
@@ -335,526 +29,13 @@ def tray_settings_scroll_and_footer(
scrollbar_button_hover_color=theme.text_secondary, scrollbar_button_hover_color=theme.text_secondary,
) )
scroll.pack(fill="both", expand=True) scroll.pack(fill="both", expand=True)
try:
scroll._parent_canvas.configure(yscrollincrement=4)
except Exception:
pass
return scroll, footer return scroll, footer
def _config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
) -> TrayConfigFormWidgets:
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text="Настройки",
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0))
appearance_var = ctk.StringVar(
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто")
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto")
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
cfg["appearance"] = cfg_val
ctk.CTkComboBox(
header,
values=_APPEARANCE_OPTIONS,
variable=appearance_var,
width=102,
height=28,
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
fg_color=theme.field_bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
command=_on_appearance_change,
).pack(side="right")
ctk.CTkButton(
header, text="Donate ♥", width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
),
).pack(side="right", padx=(0, 6))
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col, host_var = _labeled_entry(
ctk, host_row, theme, "IP-адрес",
cfg.get("host", default_config["host"]),
tip=_TIP_HOST, width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = _labeled_entry(
ctk, host_row, theme, "Порт",
cfg.get("port", default_config["port"]),
tip=_TIP_PORT, width=100,
)
port_col.pack(side="left")
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = _labeled_entry(
ctk, secret_row, theme, "Secret",
cfg.get("secret", default_config["secret"]),
tip=_TIP_SECRET, width=160, pack_fill=True,
)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="↺", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4))
cfproxy_var = ctk.BooleanVar(
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
_cf_test_btn = [None]
def _on_cf_test():
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
btn = _cf_test_btn[0]
if btn:
btn.configure(text="...", state="disabled")
import threading as _threading
if user_domains:
def _worker():
try:
per = _run_cfproxy_multi_test(user_domains)
if btn:
btn.after(
0,
lambda: _show_multi_connectivity_results(
"CF-прокси", 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="Тест", state="normal"))
_threading.Thread(target=_worker, daemon=True).start()
else:
def _worker_auto():
try:
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
if btn:
btn.after(
0,
lambda: _show_connectivity_results(
"CF-прокси", res,
domain=ok_domain or '',
auto_mode=True,
unavailable_message=(
"\u2717 Ни один из автоматических CF-доменов не отвечает."
),
),
)
except Exception as exc:
log.error("CF proxy auto-test failed: %s", exc)
finally:
if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
_threading.Thread(target=_worker_auto, daemon=True).start()
_cf_test_widget = ctk.CTkButton(
cf_row, text="Тест", width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack(side="right")
_cf_test_btn[0] = _cf_test_widget
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x")
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
cf_domain_entry = _entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8,
)
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN)
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
if not cf_custom_cb_var.get():
cfproxy_user_domain_var.set("")
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker")
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домены (через запятую)", size=11)
cf_worker_lbl.pack(anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
cfproxy_worker_domain_var = ctk.StringVar(
value=", ".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], _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="...", state="disabled")
import threading as _threading
def _worker():
try:
per = _run_cfworker_multi_test(domains)
btn.after(
0,
lambda: _show_multi_connectivity_results(
"CF Worker", per, label_prefix='DC',
),
)
except Exception as exc:
log.error("CF worker test failed: %s", exc)
finally:
btn.after(0, lambda: btn.configure(text="Тест"))
btn.after(0, _sync_cfworker_test_button)
_threading.Thread(target=_worker, daemon=True).start()
ctk.CTkButton(
cf_worker_input, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_CFWORKER_HELP_URL),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_input, text="Тест", width=56, height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right", padx=(0, 6))
_cfworker_test_btn[0] = _cfworker_test_widget
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_test_button()
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB),
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
]
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = _label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
adv_e = _entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status()
check_updates_var = ctk.BooleanVar(
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске."
elif st.get("has_update") and st.get("latest"):
upd_status = (
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = (
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
else:
upd_status = "Установлена последняя известная версия с GitHub."
_label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton(
upd_inner, text="Открыть страницу релиза", height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
autostart_var = None
if show_autostart:
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = _label(
ctk, sys_inner, theme,
"Если переместить программу в другую папку, запись автозапуска может сброситься.",
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var,
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
)
def merge_adv_from_form(
widgets: TrayConfigFormWidgets,
base: Dict[str, Any],
default_config: dict,
) -> None:
for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
base[key] = val
except ValueError:
base[key] = default_config[key]
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
import socket as _sock
host_val = widgets.host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
return "Некорректный IP-адрес."
try:
port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
return "Порт должен быть числом 1-65535"
lines = [
line.strip()
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if line.strip()
]
try:
parse_dc_ip_list(lines)
except ValueError as e:
return str(e)
secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32:
return "Secret должен содержать ровно 32 hex-символа (16 байт)."
try:
bytes.fromhex(secret_val)
except ValueError:
return "Secret должен состоять только из hex-символов (0-9, a-f)."
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"secret": secret_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
if include_autostart:
new_cfg["autostart"] = (
widgets.autostart_var.get()
if widgets.autostart_var is not None
else False
)
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_user_domain_var is not None:
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
if widgets.cfproxy_worker_domain_var is not None:
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
if widgets.appearance_var is not None:
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
return new_cfg
def install_tray_config_buttons( def install_tray_config_buttons(
ctk: Any, ctk: Any,
frame: Any, frame: Any,
@@ -872,22 +53,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(
@@ -912,19 +93,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(
@@ -956,13 +137,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 сейчас", create_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",
+168
View File
@@ -0,0 +1,168 @@
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
_module_path = Path(__file__)
if not _module_path.is_absolute():
_module_path = Path.cwd() / _module_path
_LOCALES_DIR = _module_path.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())
+154
View File
@@ -0,0 +1,154 @@
{
"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",
"label.h2_enable": "Media multiplexing (HTTP/2)",
"tip.h2": "Share one HTTP/2 connection per data center and domain for media downloads through Cloudflare.\nRequires CF proxy and TLS enabled.",
"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.no_secure": "Disable TLS for CF-proxy and CF-worker",
"label.buf_kb": "Buffer, KB (default 256)",
"label.pool_size": "WebSocket session pool (default 4)",
"label.log_max_mb": "Max log size, MB (default 5)",
"label.check_updates": "Check for updates on startup",
"label.autostart": "Start on system boot",
"label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.",
"tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces",
"tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port",
"tip.secret": "Secret key for client authorization",
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
"tip.no_secure": "When enabled, the proxy will use port 80 for CF-proxy and CF-worker connections",
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
"tip.pool": "How many ready WebSocket connections to keep per data center.\n0 disables the direct DC->IP WebSocket route",
"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 HTTP/2 and 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.update": "Update ({current} → {new})",
"tray.exit": "Exit",
"dialog.restart_title": "Restart?",
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
"dialog.already_running": "Application is already running, check the tray",
"dialog.log_not_found": "Log file has not been created yet.",
"dialog.ctk_missing": "customtkinter is not installed.",
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
"dialog.copy_fail": "Failed to copy link:\n{error}",
"dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}",
"dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
"dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}",
"dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.",
"dialog.log_open_fail": "Failed to open log file:\n{error}",
"dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}",
"update.available": "New version available: {version}",
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
"update.downloading": "Downloading...",
"update.replacing": "Replacing file...",
"update.restarting": "Restarting...",
"update.error": "Error: {msg}",
"update.download_fail": "Download failed:\n{error}",
"update.rename_fail": "Failed to rename file:\n{error}",
"update.move_fail": "Failed to move file:\n{error}",
"error.dc_config": "DC → IP configuration error.",
"diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.",
"diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 10000–50000 in settings, check firewall/antivirus, and restart.",
"diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.",
"ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once."
}
+154
View File
@@ -0,0 +1,154 @@
{
"app.name": "TG WS Proxy",
"app.error_title": "TG WS Proxy — Ошибка",
"app.settings_title": "TG WS Proxy — Настройки",
"app.update_title": "TG WS Proxy — обновление",
"language.ru": "Русский",
"language.en": "English",
"appearance.auto": "Авто",
"appearance.light": "Светлая",
"appearance.dark": "Тёмная",
"settings.title": "Настройки",
"settings.language": "Language",
"settings.theme": "Тема",
"section.interface": "Интерфейс",
"section.mtproto": "Подключение MTProto",
"section.dc": "Датацентры Telegram (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"label.h2_enable": "Мультиплексирование медиа (HTTP/2)",
"tip.h2": "Объединять загрузку медиа через Cloudflare в общее HTTP/2-соединение для каждого датацентра и домена.\nТребуется включённый CF-прокси и включённый TLS.",
"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.no_secure": "Выключить TLS для CF-прокси и CF-worker",
"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.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько готовых WebSocket-соединений держать для каждого датацентра.\n0 отключает прямой DC->IP WebSocket-маршрут",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
"tip.check_updates": "При запуске проверять наличие обновлений",
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для HTTP/2 и 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.update": "Обновить ({current} → {new})",
"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Это предупреждение будет показано только один раз."
}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import math
import socket
from copy import deepcopy
from dataclasses import dataclass
from typing import Union
from proxy import coerce_domain_list, parse_dc_ip_list
from ui.i18n import t
UI_ONLY_KEYS = frozenset({
"appearance", "autostart", "check_updates", "language"
})
@dataclass(frozen=True)
class SettingsChange:
config: dict
changed_keys: frozenset
@property
def requires_restart(self) -> bool:
return bool(self.changed_keys - UI_ONLY_KEYS)
def prepare_settings(current: dict, values: dict, defaults: dict) -> SettingsChange:
config = deepcopy(defaults)
config.update(deepcopy(current))
changed_keys = frozenset(
key for key, value in values.items() if value != config.get(key)
)
config.update(deepcopy(values))
return SettingsChange(config, changed_keys)
def validate_settings(values: dict, defaults: dict) -> Union[dict, str]:
config = dict(values)
host = values["host"].strip()
try:
socket.inet_aton(host)
except OSError:
return t("validation.bad_host")
try:
port = int(values["port"].strip())
if not 1 <= port <= 65535:
raise ValueError
except ValueError:
return t("validation.bad_port")
lines = [line.strip() for line in values["dc_ip"].splitlines() if line.strip()]
try:
parse_dc_ip_list(lines)
except ValueError as exc:
entry = getattr(exc, "entry", None)
if entry is None:
return str(exc)
key = "dc_format" if getattr(exc, "kind", "invalid") == "format" else "dc_invalid"
return t(f"validation.{key}", entry=entry)
secret = values["secret"].strip()
if len(secret) != 32:
return t("validation.bad_secret_len")
if any(char not in "0123456789abcdefABCDEF" for char in secret):
return t("validation.bad_secret_hex")
config.update(host=host, port=port, secret=secret, dc_ip=lines)
for key in ("buf_kb", "pool_size", "log_max_mb"):
try:
value = float(values[key].strip())
if not math.isfinite(value):
raise ValueError
config[key] = int(value) if key != "log_max_mb" else value
except (ValueError, OverflowError):
config[key] = defaults[key]
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
if key in config:
config[key] = coerce_domain_list(config[key])
return config
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import logging
import threading
from copy import deepcopy
from tkinter import messagebox
from ui.settings_form import validate_config_form
from ui.i18n import set_language, t
from ui.settings import prepare_settings
log = logging.getLogger("tg-ws-tray")
class SettingsDialog:
def __init__(
self, *, ctk, root, widgets, config, defaults, persist,
refresh_menu, finish, restart, include_autostart=False,
apply_autostart=None,
):
self.ctk = ctk
self.root = root
self.widgets = widgets
self.config = config
self.defaults = defaults
self.persist = persist
self.refresh_menu = refresh_menu
self.finish = finish
self.restart = restart
self.include_autostart = include_autostart
self.apply_autostart = apply_autostart
self.closed = False
def cancel(self):
if self.closed:
return
mode = self.config.get("appearance", "auto")
self.ctk.set_appearance_mode("system" if mode == "auto" else mode)
set_language(self.config.get("language", self.defaults["language"]))
self.refresh_menu()
self._finish()
def _finish(self):
self.closed = True
self.finish()
def save(self):
if self.closed:
return
values = validate_config_form(
self.widgets, self.defaults, include_autostart=self.include_autostart,
)
if isinstance(values, str):
messagebox.showerror(t("app.error_title"), values, parent=self.root)
return
change = prepare_settings(self.config, values, self.defaults)
if not change.changed_keys:
self._finish()
return
try:
self.persist(change.config)
except (OSError, ValueError, TypeError) as exc:
log.exception("Failed to save settings")
messagebox.showerror(t("app.error_title"), str(exc), parent=self.root)
return
self.config.update(deepcopy(change.config))
set_language(change.config["language"])
log.info("Settings saved: %s", ", ".join(sorted(change.changed_keys)))
if self.apply_autostart is not None:
self.apply_autostart(bool(change.config.get("autostart", False)))
self.refresh_menu()
do_restart = change.requires_restart and messagebox.askyesno(
t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root,
)
self._finish()
if do_restart:
threading.Thread(
target=self.restart, args=(deepcopy(change.config),),
daemon=True, name="proxy-restart",
).start()
+593
View File
@@ -0,0 +1,593 @@
from __future__ import annotations
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Union
from proxy import __version__, coerce_domain_list
from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.background_task import BackgroundTask
from ui.connectivity import (
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
show_connectivity_results, show_multi_connectivity_results,
)
from ui.ctk_controls import (
create_entry, create_checkbox, create_label, create_labeled_entry,
create_config_section, create_combobox,
)
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
from ui.settings import validate_settings
from ui.i18n import (
label_from_language, language_from_label, language_option_labels,
set_language, get_language, t,
)
_INNER_W = 396
_APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _get_doc_url(doc_name: str) -> str:
lang = get_language().value
lang_folder = "EN" if lang == "en" else "RU"
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
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"
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
no_secure_var: Any
advanced_vars: Dict[str, Any]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
h2_var: Optional[Any] = None
cfproxy_user_domain_enabled_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_enabled_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
set_language(cfg.get("language", default_config["language"]))
no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False))
_create_header(ctk, frame, theme)
appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config)
host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config)
dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config)
cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
verbose_var, advanced_vars = _create_logging(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click)
autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value)
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
advanced_vars=advanced_vars,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
h2_var=h2_var,
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
language_var=language_var,
)
def _create_header(ctk, frame, theme):
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0))
ctk.CTkButton(
header, text="Donate ♥", width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open(_get_doc_url("Funding")),
),
).pack(side="right", padx=(0, 6))
def _create_interface(ctk, frame, theme, cfg, default_config):
lang_cfg = cfg.get("language", default_config["language"])
appearance_var = ctk.StringVar(
master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto"))
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
ui_inner = create_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(master=frame, value=label_from_language(lang_cfg))
create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, lang_col, theme, variable=language_var,
values=[label for _, label in language_option_labels()],
).pack(fill="x")
create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, theme_col, theme, variable=appearance_var,
values=_appearance_options(), command=_on_appearance_change,
).pack(fill="x")
return appearance_var, language_var
def _create_connection(ctk, frame, theme, cfg, default_config):
conn = create_config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col, host_var = create_labeled_entry(
ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]),
tip=t("tip.host"), width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = create_labeled_entry(
ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]),
tip=t("tip.port"), width=100,
)
port_col.pack(side="left")
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = create_labeled_entry(
ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]),
tip=t("tip.secret"), width=160, pack_fill=True,
)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="↺", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
return host_var, port_var, secret_var
def _create_dc(ctk, frame, theme, cfg, default_config):
dc_inner = create_config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
return dc_textbox
def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4))
cfproxy_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
cf_test = BackgroundTask(frame.winfo_toplevel())
def _on_cf_test():
secure = not no_secure_var.get()
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
if cf_test.running:
return
_cf_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cf_test_widget.configure(text=t("button.test"), state="normal")
if user_domains:
cf_test.start(
lambda: run_cfproxy_multi_test(user_domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfproxy_title"), per, label_prefix="kws",
parent=frame.winfo_toplevel(),
),
finish,
)
else:
domains = list(balancer.domains)
cf_test.start(
lambda: run_cfproxy_auto_test(domains, secure=secure),
lambda result: show_connectivity_results(
t("connectivity.cfproxy_title"), result[1],
domain=result[0] or "", auto_mode=True,
unavailable_message=t("connectivity.cf_auto_fail"),
parent=frame.winfo_toplevel(),
),
finish,
)
_cf_test_widget = ctk.CTkButton(
cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack(side="right")
h2_var = ctk.BooleanVar(master=frame, value=cfg.get("h2", default_config.get("h2", True)))
h2_cb = create_checkbox(ctk, cf_inner, theme, t("label.h2_enable"), h2_var)
h2_cb.pack(anchor="w", pady=(2, 8))
attach_ctk_tooltip(h2_cb, t("tip.h2"))
def _sync_h2(*_):
enabled = cfproxy_var.get() and not no_secure_var.get() and not cfg.get("force_test_dc", False)
h2_cb.configure(state="normal" if enabled else "disabled")
cfproxy_var.trace_add("write", _sync_h2)
no_secure_var.trace_add("write", _sync_h2)
_sync_h2()
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x")
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
)
cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains))
cf_domain_entry = create_entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8,
)
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
return cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var
def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_worker_inner = create_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 = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
saved_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
)
cfproxy_worker_enabled_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
)
cf_worker_cb = create_checkbox(
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
cfproxy_worker_enabled_var,
)
cf_worker_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains))
cf_worker_entry = create_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"))
worker_test = BackgroundTask(frame.winfo_toplevel())
def _sync_cfworker_test_button(*_):
btn = _cfworker_test_widget
enabled = (
not worker_test.running and cfproxy_worker_enabled_var.get()
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
)
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains:
return
_cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cfworker_test_widget.configure(text=t("button.test"))
_sync_cfworker_test_button()
worker_test.start(
lambda: run_cfworker_multi_test(domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfworker_title"), per, label_prefix="DC",
parent=frame.winfo_toplevel(),
),
finish,
)
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(_get_doc_url("CfWorker")),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right")
def _sync_cfworker_entry(*_):
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
cf_worker_entry.configure(state=state)
_sync_cfworker_test_button()
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_entry()
return cfproxy_worker_enabled_var, cfproxy_worker_domain_var
def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
log_inner = create_config_section(ctk, frame, theme, t("section.logs"))
verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False))
verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_cb = create_checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
no_secure_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
(t("label.pool_size"), "pool_size", t("tip.pool")),
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
]
advanced_vars = {}
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = create_label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key])))
adv_e = create_entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
var=advanced_vars[key],
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
return verbose_var, advanced_vars
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
st = get_status()
check_updates_var = ctk.BooleanVar(
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
if st.get("error"):
upd_status = t("updates.status_error")
elif not st.get("checked"):
upd_status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"):
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
else:
upd_status = t("updates.status_latest")
create_label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
if st.get("has_update") and on_update_click is not None:
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
upd_btn_row.grid_columnconfigure(1, weight=1)
ctk.CTkButton(
upd_btn_row, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_update_click,
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
return check_updates_var
def _create_autostart(ctk, frame, theme, show_autostart, autostart_value):
autostart_var = None
if show_autostart:
sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(master=frame, value=autostart_value)
as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = create_label(
ctk, sys_inner, theme,
t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return autostart_var
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
values = {
"host": widgets.host_var.get(),
"port": widgets.port_var.get(),
"secret": widgets.secret_var.get(),
"dc_ip": widgets.dc_textbox.get("1.0", "end"),
"verbose": bool(widgets.verbose_var.get()),
}
for key, var in widgets.advanced_vars.items():
values[key] = var.get()
for key in (
"check_updates", "cfproxy", "h2", "cfproxy_user_domain_enabled",
"cfproxy_worker_enabled", "no_secure",
):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = bool(var.get())
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = var.get()
if include_autostart:
values["autostart"] = (
bool(widgets.autostart_var.get())
if widgets.autostart_var is not None else False
)
if widgets.appearance_var is not None:
values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
values["language"] = language_from_label(widgets.language_var.get()).value
return validate_settings(values, default_config)
+11 -1
View File
@@ -7,6 +7,9 @@ from __future__ import annotations
import sys import sys
import os import os
from typing import Any, Dict from typing import Any, Dict
from copy import deepcopy
from ui.i18n import detect_system_language
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = { _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1443, "port": 1443,
@@ -18,14 +21,21 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"buf_kb": 256, "buf_kb": 256,
"pool_size": 4, "pool_size": 4,
"cfproxy": True, "cfproxy": True,
"h2": True,
"cfproxy_user_domain_enabled": False,
"cfproxy_user_domain": [], "cfproxy_user_domain": [],
"cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [], "cfproxy_worker_domain": [],
"force_test_dc": False,
"no_secure": False,
"appearance": "auto",
} }
def default_tray_config() -> Dict[str, Any]: def default_tray_config() -> Dict[str, Any]:
cfg = dict(_TRAY_DEFAULTS_COMMON) cfg = deepcopy(_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",
)
+137 -38
View File
@@ -3,27 +3,32 @@ 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
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
from copy import deepcopy
import psutil import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.utils import DomainCensorFilter
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 +36,64 @@ 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
try:
p = Path(base).resolve(strict=False)
except OSError:
p = Path(os.path.realpath(base))
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"
@@ -122,18 +185,36 @@ 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)
if "cfproxy_user_domain_enabled" not in data:
data["cfproxy_user_domain_enabled"] = bool(
coerce_domain_list(data.get("cfproxy_user_domain"))
)
if "cfproxy_worker_enabled" not in data:
data["cfproxy_worker_enabled"] = bool(
coerce_domain_list(data.get("cfproxy_worker_domain"))
)
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", repr(exc)) log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG) if cfg is None:
cfg = deepcopy(DEFAULT_CONFIG)
_apply_ui_language(cfg)
return cfg
def save_config(cfg: dict) -> None: def save_config(cfg: dict) -> None:
@@ -155,20 +236,17 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
root.setLevel(level) root.setLevel(level)
logging.getLogger('asyncio').setLevel(logging.WARNING) 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"))
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
if not IS_FROZEN: if not IS_FROZEN:
ch = logging.StreamHandler(sys.stdout) ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level) ch.setLevel(level)
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S")) ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
ch.addFilter(DomainCensorFilter())
root.addHandler(ch) root.addHandler(ch)
@@ -231,7 +309,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()
@@ -243,14 +321,23 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> 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", repr(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:
pending = [
task for task in asyncio.all_tasks(loop)
if not task.done()
]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(
*pending, return_exceptions=True
))
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -271,8 +358,25 @@ 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.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])) pc.cfproxy_h2_media = cfg.get("h2", DEFAULT_CONFIG["h2"])
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])) cfproxy_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
)
cfproxy_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])
)
pc.cfproxy_user_domains = (
cfproxy_user_domains
if cfg.get("cfproxy_user_domain_enabled", bool(cfproxy_user_domains))
else []
)
pc.cfproxy_worker_domains = (
cfproxy_worker_domains
if cfg.get("cfproxy_worker_enabled", bool(cfproxy_worker_domains))
else []
)
pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
pc.disable_secure = cfg.get("no_secure", DEFAULT_CONFIG["no_secure"])
return True return True
@@ -283,7 +387,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
@@ -301,6 +406,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")
@@ -308,7 +416,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)
@@ -320,19 +428,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):
@@ -355,8 +450,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()
@@ -385,9 +482,11 @@ 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:
+87 -39
View File
@@ -1,5 +1,5 @@
""" """
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей). Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304. данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
@@ -7,7 +7,6 @@
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
@@ -19,6 +18,7 @@ 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).
@@ -37,13 +37,8 @@ _state: Dict[str, Any] = {
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:
@@ -229,48 +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) -> Optional[Tuple[str, str]]: def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or [] new_assets = _state.get("assets") or []
if not assets: if not new_assets:
return None return None
# Try SHA256 match against release asset digests target_name = None
# SHA256 match
try: try:
import hashlib import hashlib
h = hashlib.sha256() data, code = fetch_release_by_tag(f"v{current_version}")
with open(exe_path, "rb") as f: if code == 200 and data:
while True: cur_assets = _extract_assets(data)
chunk = f.read(65536) if cur_assets:
if not chunk: h = hashlib.sha256()
break with open(exe_path, "rb") as f:
h.update(chunk) while True:
exe_sha = h.hexdigest().lower() chunk = f.read(65536)
for a in assets: if not chunk:
d = (a.get("digest") or "").lower() break
if d.startswith("sha256:") and d[7:] == exe_sha: h.update(chunk)
return a["url"], a["name"] 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: except Exception:
pass pass
# Fallback # Fallback
import struct if not target_name or target_name not in [a.get("name") for a in new_assets]:
is_64 = struct.calcsize("P") * 8 == 64 import platform
try: import struct
is_modern = sys.getwindowsversion().major >= 10
except Exception: is_64 = struct.calcsize("P") * 8 == 64
is_modern = True machine = platform.machine().lower()
if is_modern: is_arm64 = machine in ("arm64", "aarch64")
name = "TgWsProxy_windows.exe"
elif is_64: try:
name = "TgWsProxy_windows_7_64bit.exe" is_modern = sys.getwindowsversion().major >= 10
else: except Exception:
name = "TgWsProxy_windows_7_32bit.exe" is_modern = True
for a in assets:
if a.get("name") == name: 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 a["url"], a["name"]
return None return None
+117 -108
View File
@@ -34,7 +34,7 @@ try:
except ImportError: except ImportError:
Image = None Image = None
from proxy import get_link_host from proxy import __version__, get_link_host
from utils.win32_theme import ( from utils.win32_theme import (
is_windows_dark_theme, is_windows_dark_theme,
@@ -47,20 +47,25 @@ from utils.tray_common import (
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 utils.update_check import (
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
)
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
) )
from ui.ctk_theme import ( 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 t
from ui.settings_dialog import SettingsDialog
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_win_mutex_handle = None _win_mutex_handle = None
_update_flow_lock = threading.Lock()
_ERROR_ALREADY_EXISTS = 183 _ERROR_ALREADY_EXISTS = 183
@@ -110,22 +115,23 @@ _IDYES = 6
_IDNO = 7 _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( def update_ctk_form(
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None, text: str, title: Optional[str] = None, download_url: Optional[str] = None,
release_url: Optional[str] = None, release_url: Optional[str] = None,
) -> str: ) -> str:
title = title or t("app.name")
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q) result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES: if result == _IDYES:
@@ -144,6 +150,7 @@ def update_ctk_form(
width=310 if IS_FROZEN else 210, width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100, height=130 if IS_FROZEN else 100,
theme=theme, theme=theme,
topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
frame = main_content_frame(ctk, root, theme, padx=16, pady=14) frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -194,19 +201,19 @@ def update_ctk_form(
if IS_FROZEN: if IS_FROZEN:
btn_upd = ctk.CTkButton( btn_upd = ctk.CTkButton(
row, text="Обновить", width=88, height=34, row, text=t("button.update"), width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update, font=(theme.ui_font_family, 13), command=_on_update,
) )
btn_upd.pack(side="left", padx=(0, 6)) btn_upd.pack(side="left", padx=(0, 6))
btns.append(btn_upd) btns.append(btn_upd)
btn_pg = ctk.CTkButton( btn_pg = ctk.CTkButton(
row, text="Страница", width=88, height=34, row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"), font=(theme.ui_font_family, 13), command=lambda: webbrowser.open(release_url or RELEASES_PAGE_URL),
) )
btn_pg.pack(side="left", padx=(0, 6)) btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg) btns.append(btn_pg)
btn_cl = ctk.CTkButton( btn_cl = ctk.CTkButton(
row, text="Закрыть", width=88, height=34, row, text=t("button.close"), width=88, height=34,
font=(theme.ui_font_family, 13), font=(theme.ui_font_family, 13),
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, border_color=theme.field_border, text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
@@ -231,11 +238,11 @@ def _perform_update(download_url: str, set_status=None) -> None:
def _err(msg: str) -> None: def _err(msg: str) -> None:
log.error("Update error: %s", msg) log.error("Update error: %s", msg)
if set_status: if set_status:
set_status(f"Ошибка: {msg}") set_status(f"{t('update.error', msg=msg)}")
else: else:
_show_error(msg) _show_error(msg)
_step("Скачивание...") _step(t("update.downloading"))
cur_exe = Path(sys.executable) cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe") old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None tmp_path = None
@@ -253,7 +260,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
break break
_fout.write(_chunk) _fout.write(_chunk)
except Exception as exc: except Exception as exc:
_err(f"Не удалось скачать:\n{exc}") _err(t("update.download_fail", error=exc))
if tmp_path: if tmp_path:
try: try:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
@@ -261,13 +268,13 @@ def _perform_update(download_url: str, set_status=None) -> None:
pass pass
return return
_step("Замена файла...") _step(t("update.replacing"))
try: try:
if old_exe.exists(): if old_exe.exists():
old_exe.unlink() old_exe.unlink()
cur_exe.rename(old_exe) cur_exe.rename(old_exe)
except Exception as exc: except Exception as exc:
_err(f"Не удалось переименовать файл:\n{exc}") _err(t("update.rename_fail", error=exc))
try: try:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
except OSError: except OSError:
@@ -277,7 +284,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
try: try:
tmp_path.rename(cur_exe) tmp_path.rename(cur_exe)
except Exception as exc: except Exception as exc:
_err(f"Не удалось переместить файл:\n{exc}") _err(t("update.move_fail", error=exc))
try: try:
old_exe.rename(cur_exe) old_exe.rename(cur_exe)
except OSError: except OSError:
@@ -288,7 +295,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
pass pass
return return
_step("Перезапуск...") _step(t("update.restarting"))
_release_win_mutex() _release_win_mutex()
stop_proxy() stop_proxy()
@@ -315,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
os._exit(0) os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
"""Show the update dialog for an update already known to be available.
Reused by the tray "Update" item and the settings dialog's "Update"
button, so an update can still be started after the initial startup
prompt was skipped/closed.
"""
if not _update_flow_lock.acquire(blocking=False):
return
def _show() -> None:
try:
if _exiting:
return
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
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 flow failed: %s", repr(exc))
finally:
_update_flow_lock.release()
threading.Thread(target=_show, daemon=True, name="manual-update").start()
def _maybe_do_update(cfg: dict, is_exiting) -> None: def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True): if not cfg.get("check_updates", True):
return return
@@ -324,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
if is_exiting(): if is_exiting():
return return
try: try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__) run_check(__version__)
st = get_status() if _tray_icon is not None:
if not st.get("has_update") or is_exiting(): _tray_icon.menu = _build_menu()
if is_exiting():
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL _trigger_update_flow()
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
choice = update_ctk_form(
f"Доступна новая версия: {ver}",
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc: except Exception as exc:
log.warning("Update check failed: %s", repr(exc)) log.warning("Update check failed: %s", repr(exc))
@@ -382,9 +414,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}"
) )
@@ -400,34 +430,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:
@@ -447,9 +473,9 @@ def _on_open_logs(icon=None, item=None) -> None:
os.startfile(str(LOG_FILE)) os.startfile(str(LOG_FILE))
except Exception as exc: except Exception as exc:
log.error("Failed to open log file: %s", exc) log.error("Failed to open log file: %s", exc)
_show_error(f"Не удалось открыть файл логов:\n{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:
@@ -469,13 +495,11 @@ 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)
cfg["autostart"] = is_autostart_enabled() cfg["autostart"] = is_autostart_enabled()
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
def _build(done: threading.Event) -> None: def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
@@ -484,65 +508,39 @@ 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), topmost=False, 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()
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_update_click=_trigger_update_flow,
) )
_original_appearance = ctk.get_appearance_mode()
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None: dialog = SettingsDialog(
ctk.set_appearance_mode(_original_appearance) ctk=ctk, root=root, widgets=widgets, config=_config,
_finish() defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=_finish,
restart=lambda config: restart_proxy(config, _show_error),
include_autostart=_supports_autostart(),
apply_autostart=set_autostart_enabled if _supports_autostart() else None,
)
def on_save() -> None: root.protocol("WM_DELETE_WINDOW", dialog.cancel)
from tkinter import messagebox install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
return
_ui_only_keys = {"appearance", "autostart", "check_updates"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_finish()
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build) ctk_run_dialog(_build)
@@ -565,8 +563,8 @@ 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), topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
def on_done(open_tg: bool) -> None: def on_done(open_tg: bool) -> None:
@@ -589,16 +587,27 @@ def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( items = [
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),
) ]
st = get_status()
if st.get("has_update"):
items[-2:-2] = [
pystray.Menu.SEPARATOR,
pystray.MenuItem(
t("tray.update", current=__version__, new=st.get("latest") or "?"),
_trigger_update_flow,
)
]
return pystray.Menu(*items)
# entry point # entry point
@@ -628,7 +637,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()
@@ -638,7 +647,7 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock(): 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: if IS_FROZEN: