Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bdd69ade9 | ||
|
|
50c980e9ea | ||
|
|
48954ff9ab | ||
|
|
fe1580cd83 | ||
|
|
ad7f3cae9e | ||
|
|
caa949bee0 | ||
|
|
3d59c813aa | ||
|
|
70b982da2c | ||
|
|
5226a409d4 | ||
|
|
e519c4df4c | ||
|
|
a0605c7447 | ||
|
|
222cabeee0 | ||
|
|
c2da669576 | ||
|
|
2c9a0b8a90 | ||
|
|
87977962dc | ||
|
|
5a61d8f782 | ||
|
|
f200e33fd2 | ||
|
|
c02398fbc0 | ||
|
|
5f55245184 | ||
|
|
312d844eea | ||
|
|
be485a3851 | ||
|
|
f811a10918 | ||
|
|
55ec6a91df | ||
|
|
b23c8f6122 | ||
|
|
d36bb7df71 | ||
|
|
bd4f3d080c | ||
|
|
492d999248 | ||
|
|
e8ad0d6958 | ||
|
|
5eb7e007d3 | ||
|
|
1ae2f3a147 | ||
|
|
d2b6c450eb | ||
|
|
1b8671b3d3 | ||
|
|
b2a8074c59 | ||
|
|
0d459995f3 | ||
|
|
b8a5634008 | ||
|
|
de4179305c | ||
|
|
e3958984c4 | ||
|
|
3e7e266176 | ||
|
|
fba86856db | ||
|
|
8b05ba79ae | ||
|
|
9f9e1c2482 | ||
|
|
2496004c93 | ||
|
|
b7ff77f550 | ||
|
|
2995ae7436 | ||
|
|
02e52da3a7 | ||
|
|
2a699bb91b | ||
|
|
a5b2b73dfd | ||
|
|
aee473c9f9 | ||
|
|
41e97c6a05 | ||
|
|
ecf3d6f3a1 | ||
|
|
21aaeb3aba | ||
|
|
e0230ebda7 | ||
|
|
47b8db18d9 | ||
|
|
a0545cca64 | ||
|
|
129a760996 | ||
|
|
8ac52f69b3 | ||
|
|
34cfd8cf22 | ||
|
|
88b7b55c58 | ||
|
|
57b538389c | ||
|
|
eb00122821 | ||
|
|
e81f0a973d | ||
|
|
3143eb7147 | ||
|
|
c3309ed11b | ||
|
|
e9b74d7d7d | ||
|
|
050dcbce44 | ||
|
|
4f9edf3072 | ||
|
|
9ff95d1222 | ||
|
|
4c19a6cce4 | ||
|
|
bb900e0c9e | ||
|
|
1cdbca8893 | ||
|
|
0df9174ce2 | ||
|
|
7fc24fea95 | ||
|
|
5c40fa2574 | ||
|
|
c8f6f8caf4 | ||
|
|
d3d30799a1 | ||
|
|
a0806d5a22 | ||
|
|
eb22fadb7a | ||
|
|
43bca3a71b | ||
|
|
6b5fd72612 | ||
|
|
85b5e7f22a | ||
|
|
fed772049b | ||
|
|
91d39a5ebe | ||
|
|
5cbac657dc | ||
|
|
ee6c34e065 | ||
|
|
ce6a456bd1 | ||
|
|
5bc5001c4d | ||
|
|
2afd80825b | ||
|
|
12fafbc8f4 | ||
|
|
5839ca2564 | ||
|
|
e40c571009 | ||
|
|
96e5b4b639 | ||
|
|
13d2b1db6d | ||
|
|
a29a1a8610 | ||
|
|
94010f1481 | ||
|
|
42172235c7 | ||
|
|
b0010af130 | ||
|
|
784a7f659b | ||
|
|
21fe672963 | ||
|
|
ed46ecce5a | ||
|
|
9562b11101 | ||
|
|
dfdb993da5 | ||
|
|
d4f8b51326 | ||
|
|
ca431633d7 | ||
|
|
ea4e8e790a | ||
|
|
05d6de269b | ||
|
|
1c4b103df2 | ||
|
|
23f0e4d426 | ||
|
|
49e62ca142 | ||
|
|
5915a0e1f3 | ||
|
|
7bc9e133c8 | ||
|
|
12d3d5e478 | ||
|
|
b7cca232ea | ||
|
|
0eebdff69e | ||
|
|
ab3bec967c | ||
|
|
a16f7dfc0b | ||
|
|
6f02fc1c46 | ||
|
|
884fffcc2f | ||
|
|
09ce00b2e0 | ||
|
|
362c5a4893 | ||
|
|
bff67b3ecf | ||
|
|
d5abfbf9c2 | ||
|
|
8269ebe3bb | ||
|
|
3770569789 | ||
|
|
e72a44d74b | ||
|
|
33d3147c0b | ||
|
|
e46cf138ac | ||
|
|
145b0c431a | ||
|
|
b991948a77 | ||
|
|
42df9cfdc4 | ||
|
|
eabc0c9a15 | ||
|
|
ea88adc2dc | ||
|
|
f554f730c2 | ||
|
|
f85c4fe854 | ||
|
|
b070647996 | ||
|
|
e3d2229f7f | ||
|
|
1cbc7dee7d | ||
|
|
1728fabfc7 | ||
|
|
bf08bad11e | ||
|
|
a787b4fd6b | ||
|
|
2c577c1166 | ||
|
|
05b22fe3ba | ||
|
|
cc00c6d040 | ||
|
|
b3ed5c09db | ||
|
|
b8556dc702 | ||
|
|
28be00ea9e | ||
|
|
5795de00b1 | ||
|
|
c5fa5b7f3e | ||
|
|
a70e50b9f3 | ||
|
|
059ca8760f | ||
|
|
0c8d0f160a | ||
|
|
791708cc3d | ||
|
|
1abcbf86fe | ||
|
|
d84b9eadc4 | ||
|
|
c1b4cb0204 | ||
|
|
5d08e16e5d | ||
|
|
a844a88f38 | ||
|
|
e5f1d02737 | ||
|
|
3a6e82c2a8 | ||
|
|
e56ada1a34 | ||
|
|
b44d79a933 | ||
|
|
77723d875f | ||
|
|
548ec05fc5 | ||
|
|
03c7719c39 | ||
|
|
db4cebe0b2 | ||
|
|
ca81d037f7 | ||
|
|
07615af49c | ||
|
|
f8ee37370d | ||
|
|
4cbb9e555c | ||
|
|
25ae4b0a24 | ||
|
|
8af1bc8c89 | ||
|
|
b48ac67b9f | ||
|
|
937acdb461 | ||
|
|
6f3da84e48 | ||
|
|
3c3e9eb34b | ||
|
|
ba89cad8b8 | ||
|
|
bf905ec54f | ||
|
|
ace0a5e968 | ||
|
|
e47eef4709 | ||
|
|
abe1d1f01e | ||
|
|
cc31c02c9d | ||
|
|
f39bb15ff6 | ||
|
|
5a62cd82b2 | ||
|
|
fe4e0e8234 | ||
|
|
172dc67093 | ||
|
|
c5c2907fa8 | ||
|
|
26b95ffa0f | ||
|
|
3dfcc27932 | ||
|
|
6e0e567790 | ||
|
|
bc79a5e4c1 | ||
|
|
ce83b78bac | ||
|
|
a6235f3594 | ||
|
|
c0d9b5f8e1 | ||
|
|
4041fd9f05 | ||
|
|
dd09f24449 | ||
|
|
dd666489e3 | ||
|
|
3af0cd75a2 | ||
|
|
535d4126ed | ||
|
|
44e754ded0 | ||
|
|
71be4461d3 | ||
|
|
9279399f00 | ||
|
|
557c92b9a3 | ||
|
|
c883674ad0 | ||
|
|
df98baf961 | ||
|
|
34dde32033 | ||
|
|
b8bd062663 | ||
|
|
8e1e3fcc45 | ||
|
|
097bb9d0b7 | ||
|
|
19fbf7494a | ||
|
|
4b0bc2f4d2 | ||
|
|
7850e1f5b4 | ||
|
|
63d5bafd3e | ||
|
|
7eaba0b29c | ||
|
|
6c94d3a39d | ||
|
|
746cd66b35 | ||
|
|
e5d8ff7769 | ||
|
|
3ee82e5114 | ||
|
|
db1308e3f5 | ||
|
|
6231499c39 | ||
|
|
826554abfb | ||
|
|
7f44c524c8 | ||
|
|
6310fcd6eb | ||
|
|
081b150b3d | ||
|
|
15001980dc | ||
|
|
da4b521aba | ||
|
|
07facfe18c | ||
|
|
7a886dff26 | ||
|
|
17e37f9ca0 | ||
|
|
968827445f | ||
|
|
be8d178e5c | ||
|
|
46426c45b0 | ||
|
|
c4a044542c | ||
|
|
af74009b11 | ||
|
|
6766db9812 | ||
|
|
95f99be26b | ||
|
|
0d11062c92 | ||
|
|
b3a9bc6a8f | ||
|
|
c179c299bb | ||
|
|
bd4746004e | ||
|
|
77a0b837d9 | ||
|
|
5d28a50740 | ||
|
|
7a1e2f3f5b | ||
|
|
c0183bf448 | ||
|
|
f95b9b7da0 | ||
|
|
f3d05f7efc | ||
|
|
e3d4578eed | ||
|
|
e1004e5e73 | ||
|
|
4304c71f89 | ||
|
|
3cb1929dc8 | ||
|
|
afb7c5f56d | ||
|
|
18a1bced83 | ||
|
|
ed85e2a284 | ||
|
|
c1452c23da | ||
|
|
6a80ca85e3 | ||
|
|
4ae7cb92f7 | ||
|
|
7eeb447a76 | ||
|
|
5d839c1112 | ||
|
|
0dc2a9cac6 | ||
|
|
7943c539b6 | ||
|
|
5e53a8a470 | ||
|
|
692157b0f5 | ||
|
|
26542558c6 | ||
|
|
e6ee4e6159 | ||
|
|
96383057c6 | ||
|
|
646468680c | ||
|
|
51aca9009f | ||
|
|
6b9ddda7f0 | ||
|
|
54c6f3881b | ||
|
|
99b5c722e1 | ||
|
|
9924440c48 | ||
|
|
7572258a28 | ||
|
|
d2190cfec6 | ||
|
|
053ec3e00f | ||
|
|
55affaf78f | ||
|
|
533420b516 | ||
|
|
473078593a | ||
|
|
46011c0ff5 | ||
|
|
8219b9f144 | ||
|
|
cf3e3b2aec | ||
|
|
3fdce27fbb | ||
|
|
1433c2e881 | ||
|
|
f774777539 | ||
|
|
b6cb5aa76f | ||
|
|
7574357db9 | ||
|
|
2571847a9e |
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
packaging/
|
||||||
|
windows.py
|
||||||
|
icon.ico
|
||||||
|
*.spec
|
||||||
|
*.spec.bak
|
||||||
|
*.manifest
|
||||||
|
*.log
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.py text diff=python
|
||||||
|
*.spec text linguist-language=Python
|
||||||
|
|
||||||
|
*.toml text
|
||||||
|
*.txt text
|
||||||
|
|
||||||
|
*.ico binary
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Default owners
|
||||||
|
* @Flowseal
|
||||||
|
|
||||||
|
# Automation and repository settings
|
||||||
|
.github/** @Flowseal
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/** @Flowseal
|
||||||
|
|
||||||
|
# Core proxy implementation
|
||||||
|
proxy/** @Flowseal
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: 🐛 Проблема
|
||||||
|
title: '[Проблема] '
|
||||||
|
description: Сообщить о проблеме
|
||||||
|
labels: ['bug']
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: app_version
|
||||||
|
attributes:
|
||||||
|
label: Версия TG WS Proxy
|
||||||
|
description: Укажите версию приложения (например, v1.2.3)
|
||||||
|
placeholder: vX.Y.Z
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Опишите вашу проблему
|
||||||
|
description: Чётко опишите проблему, с которой вы столкнулись
|
||||||
|
placeholder: Описание проблемы
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: 📚 Документация
|
||||||
|
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
|
||||||
|
about: Ознакомьтесь с документацией перед созданием issue
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: 🚀 Предложение
|
||||||
|
title: '[Предложение] '
|
||||||
|
description: Предложить улучшение или новую функциональность
|
||||||
|
labels: ['enhancement']
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Предлагаемое решение
|
||||||
|
description: Опишите, как именно вы предлагаете улучшить проект
|
||||||
|
placeholder: |
|
||||||
|
Предлагаю добавить ...
|
||||||
|
Это позволит ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Для какой платформы актуально?
|
||||||
|
description: Выберите платформу, если предложение связано с конкретной ОС
|
||||||
|
options:
|
||||||
|
- Все платформы
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
- Другое
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Дополнительный контекст
|
||||||
|
description: Добавьте примеры, ссылки, скриншоты или другие детали
|
||||||
|
placeholder: Любые дополнительные материалы по предложению
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
virkgj.com
|
||||||
|
vmmzovy.com
|
||||||
|
mkuosckvso.com
|
||||||
|
zaewayzmplad.com
|
||||||
|
twdmbzcm.com
|
||||||
|
awzwsldi.com
|
||||||
|
clngqrflngqin.com
|
||||||
|
tjacxbqtj.com
|
||||||
|
bxaxtxmrw.com
|
||||||
|
dmohrsgmohcrwb.com
|
||||||
|
vwbmtmoi.com
|
||||||
|
khgrre.com
|
||||||
|
ulihssf.com
|
||||||
|
tmhqsdqmfpmk.com
|
||||||
|
xwuwoqbm.com
|
||||||
|
orgcnunpj.com
|
||||||
|
zhkuldz.com
|
||||||
|
zypoljnslxa.com
|
||||||
|
efabnxaowuzs.com
|
||||||
|
zaftuzsftqdq.com
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
trusted_users:
|
||||||
|
- Flowseal
|
||||||
@@ -3,88 +3,568 @@ name: Build & Release
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
|
make_release:
|
||||||
|
description: 'Create Github Release?'
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
version:
|
version:
|
||||||
description: "Release version tag (e.g. v1.0.0)"
|
description: "Release version tag (e.g. v1.0.0)"
|
||||||
required: true
|
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:
|
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@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.11"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Setup MSVC 14.40 toolset
|
||||||
run: pip install -r requirements.txt
|
uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
with:
|
||||||
|
toolset: 14.40
|
||||||
|
|
||||||
- name: Install pyinstaller
|
- name: Install dependencies
|
||||||
run: pip install pyinstaller
|
run: pip install .
|
||||||
|
|
||||||
|
- name: Build PyInstaller bootloader from source
|
||||||
|
env:
|
||||||
|
PYINSTALLER_COMPILE_BOOTLOADER: "1"
|
||||||
|
run: |
|
||||||
|
pip download --no-binary pyinstaller --no-deps --no-cache-dir -d pyinstaller_src "pyinstaller==6.10.0"
|
||||||
|
pip install (Get-ChildItem pyinstaller_src\*.tar.gz).FullName
|
||||||
|
|
||||||
- name: Build EXE with PyInstaller
|
- name: Build EXE with PyInstaller
|
||||||
run: pyinstaller packaging/windows.spec --noconfirm
|
run: pyinstaller packaging/windows.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Strip Rich PE header
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
import struct, pathlib
|
||||||
|
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||||
|
data = bytearray(exe.read_bytes())
|
||||||
|
rich = data.find(b'Rich')
|
||||||
|
if rich == -1:
|
||||||
|
raise SystemExit('Rich header not found')
|
||||||
|
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||||
|
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||||
|
ds = data.find(dans)
|
||||||
|
if ds == -1:
|
||||||
|
raise SystemExit('DanS marker not found')
|
||||||
|
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||||
|
exe.write_bytes(data)
|
||||||
|
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Rename artifact
|
||||||
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy
|
name: TgWsProxy-windows-x64
|
||||||
path: |
|
path: dist/TgWsProxy_windows.exe
|
||||||
dist/TgWsProxy.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:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x64
|
||||||
|
suffix: 64bit
|
||||||
|
- arch: x86
|
||||||
|
suffix: 32bit
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v6
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Python 3.8 (last version supporting Win7)
|
- uses: actions/setup-python@v6
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.8"
|
python-version: "3.8"
|
||||||
|
architecture: ${{ matrix.arch }}
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install dependencies & pyinstaller
|
||||||
|
run: pip install . "pyinstaller==5.13.2"
|
||||||
|
|
||||||
- name: Install dependencies (Win7-compatible)
|
- name: Build EXE with PyInstaller
|
||||||
run: pip install -r requirements-win7.txt
|
|
||||||
|
|
||||||
- name: Install pyinstaller
|
|
||||||
run: pip install "pyinstaller==5.13.2"
|
|
||||||
|
|
||||||
- name: Build EXE with PyInstaller (Win7)
|
|
||||||
run: pyinstaller packaging/windows.spec --noconfirm
|
run: pyinstaller packaging/windows.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Strip Rich PE header
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
import struct, pathlib
|
||||||
|
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||||
|
data = bytearray(exe.read_bytes())
|
||||||
|
rich = data.find(b'Rich')
|
||||||
|
if rich == -1:
|
||||||
|
raise SystemExit('Rich header not found')
|
||||||
|
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||||
|
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||||
|
ds = data.find(dans)
|
||||||
|
if ds == -1:
|
||||||
|
raise SystemExit('DanS marker not found')
|
||||||
|
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||||
|
exe.write_bytes(data)
|
||||||
|
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||||
|
"
|
||||||
|
|
||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy-win7.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy-win7
|
name: TgWsProxy-win7-${{ matrix.suffix }}
|
||||||
path: dist/TgWsProxy-win7.exe
|
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
if: ${{ github.event.inputs.build_macos == 'true' }}
|
||||||
|
env:
|
||||||
|
CFFI_VERSION: "2.0.0"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install universal2 Python
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -LO https://www.python.org/ftp/python/3.12.10/python-3.12.10-macos11.pkg
|
||||||
|
sudo installer -pkg python-3.12.10-macos11.pkg -target /
|
||||||
|
echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3.12 -m pip install --upgrade pip setuptools wheel
|
||||||
|
python3.12 -m pip install delocate==0.13.0
|
||||||
|
|
||||||
|
mkdir -p wheelhouse/arm64 wheelhouse/x86_64 wheelhouse/universal2
|
||||||
|
|
||||||
|
python3.12 -m pip download \
|
||||||
|
--only-binary=:all: \
|
||||||
|
--platform macosx_11_0_arm64 \
|
||||||
|
--python-version 3.12 \
|
||||||
|
--implementation cp \
|
||||||
|
-d wheelhouse/arm64 \
|
||||||
|
"cffi==$CFFI_VERSION" \
|
||||||
|
Pillow==12.1.0 \
|
||||||
|
psutil==7.0.0
|
||||||
|
|
||||||
|
python3.12 -m pip download \
|
||||||
|
--only-binary=:all: \
|
||||||
|
--platform macosx_10_13_x86_64 \
|
||||||
|
--python-version 3.12 \
|
||||||
|
--implementation cp \
|
||||||
|
-d wheelhouse/x86_64 \
|
||||||
|
"cffi==$CFFI_VERSION" \
|
||||||
|
Pillow==12.1.0
|
||||||
|
|
||||||
|
python3.12 -m pip download \
|
||||||
|
--only-binary=:all: \
|
||||||
|
--platform macosx_10_9_x86_64 \
|
||||||
|
--python-version 3.12 \
|
||||||
|
--implementation cp \
|
||||||
|
-d wheelhouse/x86_64 \
|
||||||
|
psutil==7.0.0
|
||||||
|
|
||||||
|
delocate-merge \
|
||||||
|
wheelhouse/arm64/cffi-*.whl \
|
||||||
|
wheelhouse/x86_64/cffi-*.whl \
|
||||||
|
-w wheelhouse/universal2
|
||||||
|
|
||||||
|
delocate-merge \
|
||||||
|
wheelhouse/arm64/pillow-12.1.0-*.whl \
|
||||||
|
wheelhouse/x86_64/pillow-12.1.0-*.whl \
|
||||||
|
-w wheelhouse/universal2
|
||||||
|
|
||||||
|
delocate-merge \
|
||||||
|
wheelhouse/arm64/psutil-7.0.0-*.whl \
|
||||||
|
wheelhouse/x86_64/psutil-7.0.0-*.whl \
|
||||||
|
-w wheelhouse/universal2
|
||||||
|
|
||||||
|
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
|
||||||
|
python3.12 -m pip install .
|
||||||
|
python3.12 -m pip install pyinstaller==6.13.0
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3.12 macos.py --render-app-icon icon.icns
|
||||||
|
|
||||||
|
- name: Build app with PyInstaller
|
||||||
|
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Validate universal2 app bundle
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
|
||||||
|
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
||||||
|
test -n "$ICON_FILE"
|
||||||
|
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
|
||||||
|
test -d "dist/TG WS Proxy.app/Contents/Resources/customtkinter"
|
||||||
|
|
||||||
|
found=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if file "$file" | grep -q "Mach-O"; then
|
||||||
|
found=1
|
||||||
|
archs="$(lipo -archs "$file" 2>/dev/null || true)"
|
||||||
|
case "$archs" in
|
||||||
|
*arm64*x86_64*|*x86_64*arm64*) ;;
|
||||||
|
*)
|
||||||
|
echo "Missing universal2 slices in $file: ${archs:-unknown}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done < <(find "dist/TG WS Proxy.app" -type f -print0)
|
||||||
|
|
||||||
|
if [ "$found" -eq 0 ]; then
|
||||||
|
echo "No Mach-O files found in app bundle" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
packaging/dmg/build_dmg.sh \
|
||||||
|
"dist/TG WS Proxy.app" \
|
||||||
|
"TG WS Proxy" \
|
||||||
|
"dist/TgWsProxy_macos_universal.dmg"
|
||||||
|
|
||||||
|
- name: Validate DMG
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
|
||||||
|
MOUNT_DIR="$(mktemp -d)"
|
||||||
|
DEVICE="$(hdiutil attach \
|
||||||
|
-readonly \
|
||||||
|
-nobrowse \
|
||||||
|
-mountpoint "$MOUNT_DIR" \
|
||||||
|
"$DMG" \
|
||||||
|
| awk '/^\/dev\// { print $1; exit }')"
|
||||||
|
|
||||||
|
test -d "$MOUNT_DIR/TG WS Proxy.app"
|
||||||
|
test -L "$MOUNT_DIR/Applications"
|
||||||
|
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
|
||||||
|
test -f "$MOUNT_DIR/.background/background.tiff"
|
||||||
|
test -f "$MOUNT_DIR/.DS_Store"
|
||||||
|
hdiutil detach "$DEVICE"
|
||||||
|
rmdir "$MOUNT_DIR"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: TgWsProxy-macOS
|
||||||
|
path: dist/TgWsProxy_macos_universal.dmg
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
if: ${{ github.event.inputs.build_linux == 'true' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
python3-venv \
|
||||||
|
python3-dev \
|
||||||
|
python3-gi \
|
||||||
|
python3-gi-cairo \
|
||||||
|
gir1.2-appindicator3-0.1 \
|
||||||
|
gir1.2-ayatanaappindicator3-0.1 \
|
||||||
|
python3-tk
|
||||||
|
|
||||||
|
- name: Create venv with system site-packages
|
||||||
|
run: python3 -m venv --system-site-packages .venv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install .
|
||||||
|
.venv/bin/pip install "pyinstaller==6.13.0"
|
||||||
|
|
||||||
|
- name: Build binary with PyInstaller
|
||||||
|
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Rename binary artifact
|
||||||
|
run: mv dist/TgWsProxy dist/TgWsProxy_linux_amd64
|
||||||
|
|
||||||
|
- name: Create .deb package
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
PKG_ROOT="pkg"
|
||||||
|
|
||||||
|
rm -rf "$PKG_ROOT"
|
||||||
|
mkdir -p \
|
||||||
|
"$PKG_ROOT/DEBIAN" \
|
||||||
|
"$PKG_ROOT/usr/bin" \
|
||||||
|
"$PKG_ROOT/usr/share/applications" \
|
||||||
|
"$PKG_ROOT/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
|
||||||
|
install -m 755 dist/TgWsProxy_linux_amd64 "$PKG_ROOT/usr/bin/tg-ws-proxy"
|
||||||
|
|
||||||
|
.venv/bin/python - <<PY
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
Image.open("icon.ico").save(
|
||||||
|
"${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
|
||||||
|
"PNG",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat > "$PKG_ROOT/usr/share/applications/tg-ws-proxy.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=TG WS Proxy
|
||||||
|
GenericName=Telegram Proxy
|
||||||
|
Comment=Telegram Desktop WebSocket Bridge Proxy
|
||||||
|
Exec=tg-ws-proxy
|
||||||
|
Icon=tg-ws-proxy
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
StartupNotify=true
|
||||||
|
Keywords=telegram;proxy;websocket;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PKG_ROOT/DEBIAN/control" <<EOF
|
||||||
|
Package: tg-ws-proxy
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: Flowseal
|
||||||
|
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
|
||||||
|
Description: Telegram Desktop WebSocket Bridge Proxy
|
||||||
|
MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
dpkg-deb --build --root-owner-group \
|
||||||
|
"$PKG_ROOT" \
|
||||||
|
"dist/TgWsProxy_linux_amd64.deb"
|
||||||
|
|
||||||
|
- name: Create .rpm package with fpm
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
sudo gem install fpm -v 1.17.0
|
||||||
|
|
||||||
|
mkdir -p rpm_package/usr/bin
|
||||||
|
mkdir -p rpm_package/usr/share/applications
|
||||||
|
mkdir -p rpm_package/usr/share/icons/hicolor/256x256/apps
|
||||||
|
|
||||||
|
cp dist/TgWsProxy_linux_amd64 rpm_package/usr/bin/tg-ws-proxy
|
||||||
|
chmod 755 rpm_package/usr/bin/tg-ws-proxy
|
||||||
|
|
||||||
|
.venv/bin/python - <<PY
|
||||||
|
from PIL import Image
|
||||||
|
Image.open("icon.ico").save(
|
||||||
|
"rpm_package/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
|
||||||
|
"PNG",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
cat > rpm_package/usr/share/applications/tg-ws-proxy.desktop <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=TG WS Proxy
|
||||||
|
GenericName=Telegram Proxy
|
||||||
|
Comment=Telegram Desktop WebSocket Bridge Proxy
|
||||||
|
Exec=tg-ws-proxy
|
||||||
|
Icon=tg-ws-proxy
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
StartupNotify=true
|
||||||
|
Keywords=telegram;proxy;websocket;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > post_install.sh <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
if [ -x /usr/bin/update-desktop-database ]; then
|
||||||
|
/usr/bin/update-desktop-database &> /dev/null || :
|
||||||
|
fi
|
||||||
|
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||||
|
/usr/bin/gtk-update-icon-cache -q /usr/share/icons/hicolor &> /dev/null || :
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x post_install.sh
|
||||||
|
|
||||||
|
fpm -s dir \
|
||||||
|
-t rpm \
|
||||||
|
-n tg-ws-proxy \
|
||||||
|
-v ${VERSION} \
|
||||||
|
--iteration 1 \
|
||||||
|
--architecture x86_64 \
|
||||||
|
--license "MIT" \
|
||||||
|
--vendor "Flowseal" \
|
||||||
|
--maintainer "Flowseal" \
|
||||||
|
--url "https://github.com/Flowseal/tg-ws-proxy" \
|
||||||
|
--description "MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI." \
|
||||||
|
--depends "libgtk-3.so.0()(64bit)" \
|
||||||
|
--depends "libayatana-appindicator3.so.1()(64bit)" \
|
||||||
|
--depends "python3-tkinter" \
|
||||||
|
--after-install post_install.sh \
|
||||||
|
--after-remove post_install.sh \
|
||||||
|
-C rpm_package \
|
||||||
|
.
|
||||||
|
|
||||||
|
mv tg-ws-proxy-${VERSION}-1.x86_64.rpm dist/TgWsProxy_linux_amd64.rpm
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: TgWsProxy-linux
|
||||||
|
path: |
|
||||||
|
dist/TgWsProxy_linux_amd64
|
||||||
|
dist/TgWsProxy_linux_amd64.deb
|
||||||
|
dist/TgWsProxy_linux_amd64.rpm
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build, build-win7]
|
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'
|
||||||
|
&& 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:
|
||||||
- name: Download main build
|
- uses: actions/download-artifact@v8
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy
|
pattern: TgWsProxy*
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Download Win7 build
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: TgWsProxy-win7
|
|
||||||
path: dist
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -92,11 +572,22 @@ jobs:
|
|||||||
tag_name: ${{ github.event.inputs.version }}
|
tag_name: ${{ github.event.inputs.version }}
|
||||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
## TG WS Proxy ${{ github.event.inputs.version }}
|
---
|
||||||
|
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Не можете скачать?
|
||||||
|
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
|
||||||
files: |
|
files: |
|
||||||
dist/TgWsProxy.exe
|
dist/TgWsProxy_windows.exe
|
||||||
dist/TgWsProxy-win7.exe
|
dist/TgWsProxy_windows_arm64.exe
|
||||||
|
dist/TgWsProxy_windows_7_64bit.exe
|
||||||
|
dist/TgWsProxy_windows_7_32bit.exe
|
||||||
|
dist/TgWsProxy_macos_universal.dmg
|
||||||
|
dist/TgWsProxy_linux_amd64
|
||||||
|
dist/TgWsProxy_linux_amd64.deb
|
||||||
|
dist/TgWsProxy_linux_amd64.rpm
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Auto comment on new issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
if: contains(github.event.issue.labels.*.name, 'bug')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Comment on new issue
|
||||||
|
uses: peter-evans/create-or-update-comment@v5
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.issue.number }}
|
||||||
|
body: |
|
||||||
|
### Проверьте две вещи:
|
||||||
|
- вы на последней версии: [Releases](https://github.com/Flowseal/tg-ws-proxy/releases)
|
||||||
|
- запускали по инструкции для своей ОС: [Быстрый старт](https://github.com/Flowseal/tg-ws-proxy#навигация)
|
||||||
|
|
||||||
|
## Решение частых проблем:
|
||||||
|
|
||||||
|
**Q**: Не запускается, падает с ошибкой, не работает как раньше после обновления?
|
||||||
|
**A**:
|
||||||
|
1. Удалите всё в папке Temp (или хотя бы всё, что начинается с _MEI)
|
||||||
|
2. Запускайте от имени админа
|
||||||
|
3. Попробуйте Win7 версию (если вы пользователь Windows)
|
||||||
|
4. Попробуйте отключить антивирус (если помогло, то добавьте exe в исключения). Не забудьте включить антивирус обратно.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
**Q**: Не грузит медиа? (фото/видео/стикеры)
|
||||||
|
**A**: Удалите в настройках прокси в поле **DC → IP** всё, кроме `4:149.154.167.220`. Если это не помогло, полностью очистите это поле.
|
||||||
|
|
||||||
|
|
||||||
|
#### Если проблема решена, то закройте Issue
|
||||||
|
|
||||||
|
### Если проблема осталась, пожалуйста, приложите по возможности логи.
|
||||||
|
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
|
||||||
@@ -6,6 +6,8 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.spec.bak
|
*.spec.bak
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
*.manifest
|
*.manifest
|
||||||
@@ -22,8 +24,4 @@ Thumbs.db
|
|||||||
Desktop.ini
|
Desktop.ini
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Project-specific (not for the repo)
|
/icon.icns
|
||||||
scan_ips.py
|
|
||||||
scan.txt
|
|
||||||
AyuGramDesktop-dev/
|
|
||||||
tweb-master/
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
VIRTUAL_ENV=/opt/venv
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential cargo libffi-dev libssl-dev \
|
||||||
|
&& python -m venv "$VIRTUAL_ENV" \
|
||||||
|
&& "$VIRTUAL_ENV/bin/pip" install --upgrade pip setuptools wheel \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH=/opt/venv/bin:$PATH \
|
||||||
|
TG_WS_PROXY_HOST=0.0.0.0 \
|
||||||
|
TG_WS_PROXY_PORT=1443 \
|
||||||
|
TG_WS_PROXY_SECRET="" \
|
||||||
|
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
|
||||||
|
TG_WS_PROXY_CF_WORKER=""
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system app \
|
||||||
|
&& useradd --system --gid app --create-home --home-dir /home/app app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
COPY proxy ./proxy
|
||||||
|
COPY utils ./utils
|
||||||
|
COPY docs/README.md LICENSE ./
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 1443/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||||
|
CMD []
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# TG WS Proxy
|
|
||||||
|
|
||||||
Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram.
|
|
||||||
|
|
||||||
**Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа.
|
|
||||||
|
|
||||||
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
```
|
|
||||||
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.telegram.org) → Telegram DC
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
|
|
||||||
2. Перехватывает подключения к IP-адресам Telegram
|
|
||||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
|
||||||
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены `kws{N}.web.telegram.org`
|
|
||||||
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
|
||||||
|
|
||||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
|
|
||||||
|
|
||||||
**Меню трея:**
|
|
||||||
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
|
|
||||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
|
||||||
- **Настройки...** — GUI-редактор конфигурации
|
|
||||||
- **Открыть логи** — открыть файл логов
|
|
||||||
- **Выход** — остановить прокси и закрыть приложение
|
|
||||||
|
|
||||||
## Установка из исходников
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows (Tray-приложение)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python windows.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Консольный режим
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Аргументы:**
|
|
||||||
|
|
||||||
| Аргумент | По умолчанию | Описание |
|
|
||||||
|---|---|---|
|
|
||||||
| `--port` | `1080` | Порт SOCKS5-прокси |
|
|
||||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
|
|
||||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
|
||||||
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Стандартный запуск
|
|
||||||
python proxy/tg_ws_proxy.py
|
|
||||||
|
|
||||||
# Другой порт и дополнительные DC
|
|
||||||
python proxy/tg_ws_proxy.py --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
|
||||||
|
|
||||||
# С подробным логированием
|
|
||||||
python proxy/tg_ws_proxy.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Настройка Telegram Desktop
|
|
||||||
|
|
||||||
### Автоматически
|
|
||||||
|
|
||||||
ПКМ по иконке в трее → **«Открыть в Telegram»**
|
|
||||||
|
|
||||||
### Вручную
|
|
||||||
|
|
||||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
|
||||||
2. Добавить прокси:
|
|
||||||
- **Тип:** SOCKS5
|
|
||||||
- **Сервер:** `127.0.0.1`
|
|
||||||
- **Порт:** `1080`
|
|
||||||
- **Логин/Пароль:** оставить пустыми
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"port": 1080,
|
|
||||||
"dc_ip": [
|
|
||||||
"2:149.154.167.220",
|
|
||||||
"4:149.154.167.220"
|
|
||||||
],
|
|
||||||
"verbose": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Автоматическая сборка
|
|
||||||
|
|
||||||
Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install pyinstaller
|
|
||||||
pyinstaller packaging/windows.spec
|
|
||||||
```
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# CONTRIBUTING
|
||||||
|
|
||||||
|
Спасибо за желание помочь проекту `tg-ws-proxy`.
|
||||||
|
|
||||||
|
## Перед созданием issue
|
||||||
|
|
||||||
|
1. Проверьте документацию в `docs/README.md`.
|
||||||
|
2. Убедитесь, что похожий issue еще не открыт.
|
||||||
|
3. Для корректной работы triage используйте стандартные лейблы из `.github/labels.md`.
|
||||||
|
|
||||||
|
## Как сообщать о проблемах
|
||||||
|
|
||||||
|
- Используйте шаблон `Проблема`.
|
||||||
|
- По возможности укажите:
|
||||||
|
- версию приложения,
|
||||||
|
- ОС,
|
||||||
|
- шаги воспроизведения,
|
||||||
|
- ожидаемое и фактическое поведение,
|
||||||
|
- лог-файл или текст ошибки.
|
||||||
|
|
||||||
|
Чем точнее описание, тем быстрее можно помочь.
|
||||||
|
|
||||||
|
## Локальный запуск из исходников
|
||||||
|
|
||||||
|
Требуется Python `>=3.8`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
- консольный режим: `tg-ws-proxy`
|
||||||
|
- Windows tray: `tg-ws-proxy-tray-win`
|
||||||
|
- macOS tray: `tg-ws-proxy-tray-macos`
|
||||||
|
- Linux tray: `tg-ws-proxy-tray-linux`
|
||||||
|
|
||||||
|
Подробности: `docs/BuildFromSource.md`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Тесты используют только стандартную библиотеку, дополнительные зависимости не нужны:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests -t .
|
||||||
|
```
|
||||||
|
|
||||||
|
Линтер (`ruff` настроен в `pyproject.toml`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request
|
||||||
|
|
||||||
|
Перед открытием PR:
|
||||||
|
|
||||||
|
1. Убедитесь, что изменение решает конкретную проблему.
|
||||||
|
2. Проверьте, что не сломаны существующие сценарии: запустите тесты и линтер.
|
||||||
|
3. Обновите документацию, если меняется поведение или настройка.
|
||||||
|
|
||||||
|
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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 ❤️
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**🇷🇺 Русский • [🇬🇧 English](./EN/README.md)**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### [🎉 Поддержать меня](./RU/Funding.md)
|
||||||
|
>
|
||||||
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
>
|
||||||
|
> ### Реакция антивирусов
|
||||||
|
>
|
||||||
|
> Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
|
||||||
|
> Если вы не можете скачать из-за блокировки антивирусом, то:
|
||||||
|
>
|
||||||
|
> 1) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
|
||||||
|
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||||
|
>
|
||||||
|
> Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
|
||||||
|
|
||||||
|
# TG WS Proxy
|
||||||
|
|
||||||
|
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="./images/preview-white.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
- **🚀 Быстрый старт**
|
||||||
|
- **[Windows](./RU/README.windows.md)**
|
||||||
|
- **[macOS](./RU/README.macos.md)**
|
||||||
|
- **[Linux](./RU/README.linux.md)**
|
||||||
|
- **[Docker](./RU/README.docker.md)**
|
||||||
|
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./RU/CfWorker.md)
|
||||||
|
- [Настройка Cloudflare-домена (CF-прокси)](./RU/CfProxy.md)
|
||||||
|
- [Тестовое окружение Telegram (тестовые DC)](./RU/TestDc.md)
|
||||||
|
- [Fake TLS + upstream в Nginx](./RU/FakeTlsNginx.md)
|
||||||
|
- [Файлы конфигурации Tray-приложения](./RU/TrayConfig.md)
|
||||||
|
- [Установка из исходников](./RU/BuildFromSource.md)
|
||||||
|
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
|
||||||
|
|
||||||
|
## Windows: быстрый вход
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
|
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||||
|
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||||
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||||
|
|
||||||
|
### Меню трея
|
||||||
|
|
||||||
|
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||||
|
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||||
|
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||||
|
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||||
|
- **Открыть логи** — открыть файл логов
|
||||||
|
- **Выход** — остановить прокси и закрыть приложение
|
||||||
|
|
||||||
|
### Настройка Telegram Desktop
|
||||||
|
|
||||||
|
**Автоматическая настройка**
|
||||||
|
|
||||||
|
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||||
|
|
||||||
|
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||||
|
|
||||||
|
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||||
|
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||||
|
3. Подключитесь
|
||||||
|
|
||||||
|
**Ручная настройка**
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
|
||||||
|
2. Перехватывает подключения к IP-адресам Telegram
|
||||||
|
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||||
|
4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
|
||||||
|
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> ### Не грузит фото/видео?
|
||||||
|
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
||||||
|
> **Если это не помогло, полностью очистите это поле**
|
||||||
|
> Подобная проблема встречается на аккаунтах без Premium
|
||||||
|
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./RU/CfProxy.md)
|
||||||
|
|
||||||
|
## Автоматическая сборка
|
||||||
|
|
||||||
|
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](../packaging/windows.spec), [`packaging/macos.spec`](../packaging/macos.spec), [`packaging/linux.spec`](../packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](../.github/workflows/build.yml)) для автоматической сборки.
|
||||||
|
|
||||||
|
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||||
|
|
||||||
|
- Windows 10+ x64 для `TgWsProxy_windows.exe`
|
||||||
|
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
|
||||||
|
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
||||||
|
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
||||||
|
- Intel macOS 10.15+
|
||||||
|
- Apple Silicon macOS 11.0+
|
||||||
|
- Linux x86_64 (требуется AppIndicator для системного трея)
|
||||||
|
|
||||||
|
## Контрибьюторы
|
||||||
|
|
||||||
|
Спасибо всем, кто помогает развивать проект ❤️
|
||||||
|
|
||||||
|
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
[MIT License](../LICENSE)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Установка из исходников
|
||||||
|
|
||||||
|
## Консольный прокси
|
||||||
|
|
||||||
|
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tray-приложение по ОС
|
||||||
|
|
||||||
|
### Windows 7/10+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-win
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
Требуется сборка Python с поддержкой Tk. Проверить её можно командой `python3 -m tkinter`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-macos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## Консольный режим из исходников
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Аргументы:**
|
||||||
|
|
||||||
|
| Аргумент | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port` | `1443` | Порт прокси |
|
||||||
|
| `--host` | `127.0.0.1` | Хост прокси |
|
||||||
|
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
||||||
|
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
||||||
|
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
||||||
|
| `--no-h2` | `false` | Отключить мультиплексирование медиа через HTTP/2 в CF-прокси |
|
||||||
|
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
||||||
|
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
||||||
|
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
|
||||||
|
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||||
|
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||||
|
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||||
|
| `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
|
||||||
|
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
||||||
|
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
||||||
|
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
||||||
|
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Стандартный запуск
|
||||||
|
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
|
||||||
|
|
||||||
|
# Другой порт и дополнительные DC
|
||||||
|
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||||
|
|
||||||
|
# С подробным логированием, без прямого подключения к DC
|
||||||
|
tg-ws-proxy -v --dc-ip
|
||||||
|
|
||||||
|
# Fake TLS маскировка (ee-secret)
|
||||||
|
tg-ws-proxy --fake-tls-domain example.com
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Cloudflare-прокси
|
||||||
|
|
||||||
|
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
|
||||||
|
|
||||||
|
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
|
||||||
|
|
||||||
|
## Зачем мне настраивать свой домен?
|
||||||
|
|
||||||
|
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
|
||||||
|
|
||||||
|
## Настройка своего домена
|
||||||
|
|
||||||
|
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
|
||||||
|
|
||||||
|
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
|
||||||
|
|
||||||
|
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
|
||||||
|
- Name=`kws1` IPv4=`149.154.175.50`
|
||||||
|
- Name=`kws2` IPv4=`149.154.167.51`
|
||||||
|
- Name=`kws3` IPv4=`149.154.175.100`
|
||||||
|
- Name=`kws4` IPv4=`149.154.167.91`
|
||||||
|
- Name=`kws5` IPv4=`149.154.171.5`
|
||||||
|
- Name=`kws203` IPv4=`91.105.192.100`
|
||||||
|
|
||||||
|
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
|
||||||
|
|
||||||
|
5. В настройках `TgWsProxy` замените домен на свой.
|
||||||
|
|
||||||
|
## Мультиплексирование медиа через HTTP/2
|
||||||
|
|
||||||
|
Мультиплексирование включено по умолчанию. Медиа-соединения используют `/api` вместо `/apiws` через одно общее TCP/TLS-соединение до Cloudflare для каждого DC и домена. Основные соединения и тестовые DC продолжают использовать WS.
|
||||||
|
Чтобы отключить мультиплексирование, добавьте `--no-h2` или снимите галочку
|
||||||
|
**Мультиплексирование медиа (HTTP/2)** в настройках **Cloudflare Proxy**.
|
||||||
|
|
||||||
|
## Благодарности
|
||||||
|
|
||||||
|
- Идея: https://github.com/Nekogram/WSProxy
|
||||||
|
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Cloudflare Worker
|
||||||
|
|
||||||
|
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
|
||||||
|
|
||||||
|
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
|
||||||
|
```
|
||||||
|
cloudflare.com
|
||||||
|
cloudflare.dev
|
||||||
|
workers.dev
|
||||||
|
```
|
||||||
|
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
|
||||||
|
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
|
||||||
|
3. Слева в панели выберите `Compute` → `Workers & Pages`
|
||||||
|
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
|
||||||
|
|
||||||
|
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!` → `Deploy`
|
||||||
|
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
|
||||||
|
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
|
||||||
|
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
|
||||||
|
|
||||||
|
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
|
||||||
|
* Если у вас не загружается код, то вы не выполнили первый пункт
|
||||||
|
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
|
||||||
|
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
|
||||||
|
|
||||||
|
|
||||||
|
6. Нажмите сверху справа кнопку **`Deploy`**
|
||||||
|
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
|
||||||
|
|
||||||
|
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
|
||||||
|
* Пример домена: `random-symbols-1234.username.workers.dev`
|
||||||
|
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
|
||||||
|
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
|
||||||
|
|
||||||
|
|
||||||
|
### Код Worker'а
|
||||||
|
```javascript
|
||||||
|
import { connect } from "cloudflare:sockets";
|
||||||
|
|
||||||
|
function toBytes(data) {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (data && typeof data.arrayBuffer === "function") {
|
||||||
|
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
|
||||||
|
}
|
||||||
|
return new Uint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request) {
|
||||||
|
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
|
||||||
|
return new Response("Expected websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname !== "/apiws") {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dst = url.searchParams.get("dst");
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const client = pair[0];
|
||||||
|
const server = pair[1];
|
||||||
|
server.accept();
|
||||||
|
|
||||||
|
const socket = connect({ hostname: dst, port: 443 });
|
||||||
|
const tcpReader = socket.readable.getReader();
|
||||||
|
const tcpWriter = socket.writable.getWriter();
|
||||||
|
|
||||||
|
server.addEventListener("message", async (event) => {
|
||||||
|
try {
|
||||||
|
await tcpWriter.write(await toBytes(event.data));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
server.close(1011, "tcp write failed");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener("close", async () => {
|
||||||
|
try {
|
||||||
|
await tcpWriter.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await tcpReader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
server.send(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
tcpReader.releaseLock();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Fake TLS + upstream в nginx
|
||||||
|
|
||||||
|
Домен в параметре `--fake-tls-domain` должен указывать на тот же IP, на котором запущен прокси.
|
||||||
|
|
||||||
|
## Пример `nginx.conf` для stream-модуля
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream mtproto {
|
||||||
|
server 127.0.0.1:8446;
|
||||||
|
}
|
||||||
|
|
||||||
|
map $ssl_preread_server_name $sni_name {
|
||||||
|
hostnames;
|
||||||
|
example.com mtproto;
|
||||||
|
# if you have xray with selfsni running:
|
||||||
|
# sub.example.com www;
|
||||||
|
# default xray;
|
||||||
|
}
|
||||||
|
|
||||||
|
# upstream xray {
|
||||||
|
# server 127.0.0.1:8443;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# upstream www {
|
||||||
|
# server 127.0.0.1:7443;
|
||||||
|
# }
|
||||||
|
|
||||||
|
server {
|
||||||
|
proxy_protocol on;
|
||||||
|
set_real_ip_from unix:;
|
||||||
|
listen 443;
|
||||||
|
proxy_pass $sni_name;
|
||||||
|
ssl_preread on;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск прокси за Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 proxy/tg_ws_proxy.py \
|
||||||
|
--port 8446 \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--fake-tls-domain example.com \
|
||||||
|
--proxy-protocol \
|
||||||
|
--secret <32-hex-chars>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ссылка для подключения будет в формате `ee`-секрета:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### 🎉 Поддержать меня
|
||||||
|
>
|
||||||
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
|
||||||
|
Проект полностью бесплатен для всех.
|
||||||
|
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||||
|
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# TG WS Proxy для Docker
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Вводите команды последовательно, одну за другой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачиваем репозиторий
|
||||||
|
git clone https://github.com/Flowseal/tg-ws-proxy.git
|
||||||
|
|
||||||
|
# Переходим в папку с проектом
|
||||||
|
cd tg-ws-proxy
|
||||||
|
|
||||||
|
# Собираем образ
|
||||||
|
docker build -t tg-ws-proxy .
|
||||||
|
|
||||||
|
# Запускаем контейнер
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
|
||||||
|
# Получаем ссылку для подключения
|
||||||
|
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
|
||||||
|
```
|
||||||
|
|
||||||
|
После выполнения последней команды вы увидите ссылку вида:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка параметров
|
||||||
|
|
||||||
|
Все настройки задаются переменными окружения при запуске контейнера:
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
| ----------------------- | --------------------------------- | ------------------------------------- |
|
||||||
|
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
|
||||||
|
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
|
||||||
|
| `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
|
||||||
|
| `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
|
||||||
|
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
||||||
|
|
||||||
|
Пример с ручным указанием секрета:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
-e TG_WS_PROXY_SECRET="ваш_секрет" \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Для генерации секрета можно использовать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# TG WS Proxy для Linux
|
||||||
|
|
||||||
|
## Готовые сборки
|
||||||
|
|
||||||
|
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет `TgWsProxy_linux_amd64.deb`.
|
||||||
|
|
||||||
|
Для Arch и основанных на Arch дистрибутивов подготовлены пакеты в AUR:
|
||||||
|
|
||||||
|
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
|
||||||
|
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
|
||||||
|
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Установка без AUR-helper
|
||||||
|
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
|
||||||
|
cd tg-ws-proxy-bin
|
||||||
|
makepkg -si
|
||||||
|
|
||||||
|
# При помощи AUR-helper
|
||||||
|
paru -S tg-ws-proxy-bin
|
||||||
|
|
||||||
|
# Для пакета -cli запуск через systemd (8888 — номер порта; secret можно сгенерировать командой openssl rand -hex 16)
|
||||||
|
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
|
||||||
|
```
|
||||||
|
|
||||||
|
Для остальных дистрибутивов можно использовать `TgWsProxy_linux_amd64` (бинарный файл для x86_64).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x TgWsProxy_linux_amd64
|
||||||
|
./TgWsProxy_linux_amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-linux
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# TG WS Proxy для macOS
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте `TgWsProxy_macos_universal.dmg` (универсальная сборка для Apple Silicon и Intel).
|
||||||
|
|
||||||
|
1. Откройте образ
|
||||||
|
2. Перенесите `TG WS Proxy.app` в папку `Applications`
|
||||||
|
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
|
||||||
|
|
||||||
|
Минимально поддерживаемые версии:
|
||||||
|
|
||||||
|
- Intel macOS 10.15+
|
||||||
|
- Apple Silicon macOS 11.0+
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
Для интерфейса требуются Tk, CustomTkinter и доступ к Cocoa через PyObjC. Они устанавливаются автоматически, кроме Tk, который должен входить в используемую сборку Python.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-macos
|
||||||
|
```
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# TG WS Proxy для Windows
|
||||||
|
|
||||||
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
|
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||||
|
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||||
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
|
Сборки публикуются автоматически через [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
||||||
|
|
||||||
|
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||||
|
|
||||||
|
## Меню трея
|
||||||
|
|
||||||
|
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||||
|
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||||
|
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||||
|
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||||
|
- **Открыть логи** — открыть файл логов
|
||||||
|
- **Выход** — остановить прокси и закрыть приложение
|
||||||
|
|
||||||
|
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (эту проверку можно отключить в настройках).
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
### Автоматическая настройка
|
||||||
|
|
||||||
|
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||||
|
|
||||||
|
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||||
|
|
||||||
|
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||||
|
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||||
|
3. Подключитесь
|
||||||
|
|
||||||
|
### Ручная настройка
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Портативный режим
|
||||||
|
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||||
|
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
tg-ws-proxy-tray-win
|
||||||
|
```
|
||||||
@@ -0,0 +1,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)).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Файлы конфигурации Tray-приложения
|
||||||
|
|
||||||
|
Tray-приложение хранит данные в:
|
||||||
|
|
||||||
|
- **Windows:** `%APPDATA%/TgWsProxy`
|
||||||
|
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
||||||
|
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 1443,
|
||||||
|
"secret": "...",
|
||||||
|
"dc_ip": [
|
||||||
|
"2:149.154.167.220",
|
||||||
|
"4:149.154.167.220"
|
||||||
|
],
|
||||||
|
"verbose": false,
|
||||||
|
"buf_kb": 256,
|
||||||
|
"pool_size": 4,
|
||||||
|
"log_max_mb": 5.0,
|
||||||
|
"check_updates": true,
|
||||||
|
"cfproxy": true,
|
||||||
|
"cfproxy_user_domain": "",
|
||||||
|
"cfproxy_worker_domain": "",
|
||||||
|
"force_test_dc": false,
|
||||||
|
"appearance": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
|
||||||
|
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
|
||||||
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,320 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
import pyperclip
|
||||||
|
import pystray
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
|
from proxy import get_link_host
|
||||||
|
|
||||||
|
from utils.tray_common import (
|
||||||
|
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
||||||
|
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
||||||
|
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
||||||
|
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
|
||||||
|
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||||
|
)
|
||||||
|
from ui.ctk_tray_ui import (
|
||||||
|
install_tray_config_buttons, install_tray_config_form,
|
||||||
|
populate_first_run_window, tray_settings_scroll_and_footer,
|
||||||
|
)
|
||||||
|
from ui.ctk_theme import (
|
||||||
|
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||||
|
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
|
||||||
|
_config: dict = {}
|
||||||
|
_exiting = False
|
||||||
|
|
||||||
|
# dialogs (tkinter messagebox)
|
||||||
|
|
||||||
|
|
||||||
|
def _msgbox(kind: str, text: str, title: str, **kw):
|
||||||
|
import tkinter as _tk
|
||||||
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
|
root = _tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = getattr(_mb, kind)(title, text, parent=root, **kw)
|
||||||
|
root.destroy()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||||
|
_msgbox("showerror", text, title or t("app.error_title"))
|
||||||
|
|
||||||
|
|
||||||
|
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||||
|
_msgbox("showinfo", text, title or t("app.name"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||||
|
return bool(_msgbox("askyesno", text, title or t("app.name")))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_window_icon(root) -> None:
|
||||||
|
icon_img = load_icon()
|
||||||
|
if icon_img:
|
||||||
|
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
|
||||||
|
root.iconphoto(False, root._ctk_icon_photo)
|
||||||
|
|
||||||
|
|
||||||
|
# tray callbacks
|
||||||
|
|
||||||
|
|
||||||
|
def _on_open_in_telegram(icon=None, item=None) -> None:
|
||||||
|
url = tg_proxy_url(_config)
|
||||||
|
log.info("Copying %s", url)
|
||||||
|
try:
|
||||||
|
pyperclip.copy(url)
|
||||||
|
_show_info(t("dialog.copy_ok", url=url))
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_copy_link(icon=None, item=None) -> None:
|
||||||
|
url = tg_proxy_url(_config)
|
||||||
|
log.info("Copying link: %s", url)
|
||||||
|
try:
|
||||||
|
pyperclip.copy(url)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_restart(icon=None, item=None) -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: restart_proxy(_config, _show_error), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_edit_config(icon=None, item=None) -> None:
|
||||||
|
threading.Thread(target=_edit_config_dialog, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_open_logs(icon=None, item=None) -> None:
|
||||||
|
log.info("Opening log file: %s", LOG_FILE)
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")}
|
||||||
|
subprocess.Popen(
|
||||||
|
["xdg-open", str(LOG_FILE)], env=env,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_show_info(t("dialog.log_not_found"))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(icon=None, item=None) -> None:
|
||||||
|
global _exiting
|
||||||
|
if _exiting:
|
||||||
|
os._exit(0)
|
||||||
|
return
|
||||||
|
_exiting = True
|
||||||
|
log.info("User requested exit")
|
||||||
|
quit_ctk()
|
||||||
|
threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
|
||||||
|
if icon:
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# settings dialog
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_config_dialog() -> None:
|
||||||
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
|
_show_error(t("dialog.ctk_missing"))
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = dict(_config)
|
||||||
|
|
||||||
|
def _build(done: threading.Event) -> None:
|
||||||
|
theme = ctk_theme_for_platform()
|
||||||
|
w, h = CONFIG_DIALOG_SIZE
|
||||||
|
root = create_ctk_toplevel(
|
||||||
|
ctk, title=t("app.settings_title"), width=w, height=h,
|
||||||
|
theme=theme, topmost=False, after_create=_apply_window_icon
|
||||||
|
)
|
||||||
|
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||||
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
|
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(
|
||||||
|
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||||
|
show_autostart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish() -> None:
|
||||||
|
root.destroy()
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
dialog = SettingsDialog(
|
||||||
|
ctk=ctk, root=root, widgets=widgets, config=_config,
|
||||||
|
defaults=DEFAULT_CONFIG, persist=save_config,
|
||||||
|
refresh_menu=_refresh_tray_menu, finish=_finish,
|
||||||
|
restart=lambda config: restart_proxy(config, _show_error),
|
||||||
|
)
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||||
|
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
|
||||||
|
|
||||||
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
|
|
||||||
|
# first run
|
||||||
|
|
||||||
|
|
||||||
|
def _show_first_run() -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
if FIRST_RUN_MARKER.exists():
|
||||||
|
return
|
||||||
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
|
FIRST_RUN_MARKER.touch()
|
||||||
|
return
|
||||||
|
|
||||||
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
|
|
||||||
|
def _build(done: threading.Event) -> None:
|
||||||
|
theme = ctk_theme_for_platform()
|
||||||
|
w, h = FIRST_RUN_SIZE
|
||||||
|
root = create_ctk_toplevel(
|
||||||
|
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||||
|
topmost=False, after_create=_apply_window_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_done(open_tg: bool) -> None:
|
||||||
|
FIRST_RUN_MARKER.touch()
|
||||||
|
root.destroy()
|
||||||
|
done.set()
|
||||||
|
if open_tg:
|
||||||
|
_on_open_in_telegram()
|
||||||
|
|
||||||
|
populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
|
||||||
|
|
||||||
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
|
|
||||||
|
# tray menu
|
||||||
|
|
||||||
|
|
||||||
|
def _build_menu():
|
||||||
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
link_host = get_link_host(host)
|
||||||
|
return pystray.Menu(
|
||||||
|
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||||
|
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||||
|
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||||
|
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# entry point
|
||||||
|
|
||||||
|
|
||||||
|
def run_tray() -> None:
|
||||||
|
global _tray_icon, _config
|
||||||
|
|
||||||
|
_config = load_config()
|
||||||
|
bootstrap(_config)
|
||||||
|
|
||||||
|
if pystray is None or Image is None:
|
||||||
|
log.error("pystray or Pillow not installed; running in console mode")
|
||||||
|
start_proxy(_config, _show_error)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
stop_proxy()
|
||||||
|
return
|
||||||
|
|
||||||
|
start_proxy(_config, _show_error)
|
||||||
|
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
|
||||||
|
_show_first_run()
|
||||||
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
|
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
|
||||||
|
log.info("Tray icon running")
|
||||||
|
_tray_icon.run()
|
||||||
|
|
||||||
|
stop_proxy()
|
||||||
|
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:
|
||||||
|
if sys.argv[1:] == ['--smoke-test']:
|
||||||
|
_smoke_test()
|
||||||
|
return
|
||||||
|
if not acquire_lock():
|
||||||
|
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
run_tray()
|
||||||
|
finally:
|
||||||
|
release_lock()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import faulthandler
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
Image = ImageDraw = ImageFont = None
|
||||||
|
|
||||||
|
|
||||||
|
def render_app_icon(size: int):
|
||||||
|
scale = size / 1024
|
||||||
|
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
|
||||||
|
draw.ellipse(outer, fill=(0, 151, 221, 255))
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(
|
||||||
|
"/System/Library/Fonts/Helvetica.ttc",
|
||||||
|
round(430 * scale),
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
box = draw.textbbox((0, 0), "T", font=font)
|
||||||
|
width = box[2] - box[0]
|
||||||
|
height = box[3] - box[1]
|
||||||
|
draw.text(
|
||||||
|
(
|
||||||
|
(size - width) / 2 - box[0],
|
||||||
|
(size - height) / 2 - box[1] - round(10 * scale),
|
||||||
|
),
|
||||||
|
"T",
|
||||||
|
font=font,
|
||||||
|
fill=(255, 255, 255, 255),
|
||||||
|
)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
|
||||||
|
if Image is None:
|
||||||
|
raise SystemExit("Pillow is required to render the macOS app icon")
|
||||||
|
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
|
||||||
|
render_app_icon(1024).save(output_path, format="ICNS")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import customtkinter as ctk
|
||||||
|
except ImportError:
|
||||||
|
ctk = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyperclip
|
||||||
|
except ImportError:
|
||||||
|
pyperclip = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pystray
|
||||||
|
except ImportError:
|
||||||
|
pystray = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
|
||||||
|
except ImportError:
|
||||||
|
NSApplication = None
|
||||||
|
NSApplicationActivationPolicyAccessory = None
|
||||||
|
|
||||||
|
from proxy import get_link_host
|
||||||
|
from ui.ctk_theme import (
|
||||||
|
CONFIG_DIALOG_FRAME_PAD,
|
||||||
|
CONFIG_DIALOG_SIZE,
|
||||||
|
FIRST_RUN_SIZE,
|
||||||
|
apply_ctk_appearance,
|
||||||
|
create_ctk_toplevel,
|
||||||
|
ctk_theme_for_platform,
|
||||||
|
install_tkinter_variable_del_guard,
|
||||||
|
main_content_frame,
|
||||||
|
)
|
||||||
|
from ui.ctk_tray_ui import (
|
||||||
|
install_tray_config_buttons,
|
||||||
|
install_tray_config_form,
|
||||||
|
populate_first_run_window,
|
||||||
|
tray_settings_scroll_and_footer,
|
||||||
|
)
|
||||||
|
from ui.i18n import t
|
||||||
|
from ui.settings_dialog import SettingsDialog
|
||||||
|
from utils.tray_common import (
|
||||||
|
APP_DIR,
|
||||||
|
APP_NAME,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
FIRST_RUN_MARKER,
|
||||||
|
LOG_FILE,
|
||||||
|
acquire_lock,
|
||||||
|
bootstrap,
|
||||||
|
check_ipv6_warning,
|
||||||
|
ensure_dirs,
|
||||||
|
load_config,
|
||||||
|
load_icon,
|
||||||
|
log,
|
||||||
|
maybe_notify_update,
|
||||||
|
release_lock,
|
||||||
|
restart_proxy,
|
||||||
|
save_config,
|
||||||
|
start_proxy,
|
||||||
|
stop_proxy,
|
||||||
|
tg_proxy_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
_tray_icon: Optional[Any] = None
|
||||||
|
_ctk_root: Optional[Any] = None
|
||||||
|
_settings_window: Optional[Any] = None
|
||||||
|
_ns_app: Optional[Any] = None
|
||||||
|
_config: dict = {}
|
||||||
|
_exiting = False
|
||||||
|
_crash_log: Optional[Any] = None
|
||||||
|
_ui_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_app() -> None:
|
||||||
|
if _ns_app is not None:
|
||||||
|
try:
|
||||||
|
_ns_app.activateIgnoringOtherApps_(True)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to activate macOS app: %s", repr(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _hide_ctk_root() -> None:
|
||||||
|
if _ctk_root is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_ctk_root.withdraw()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to hide CTk root: %s", repr(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(callback: Callable[[], None], delay_ms: int = 0) -> None:
|
||||||
|
if _ctk_root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def invoke() -> None:
|
||||||
|
try:
|
||||||
|
callback()
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("UI callback failed")
|
||||||
|
try:
|
||||||
|
_show_error(str(exc))
|
||||||
|
except Exception as dialog_exc:
|
||||||
|
log.error("Failed to show UI error: %s", repr(dialog_exc))
|
||||||
|
|
||||||
|
_ui_queue.put((time.monotonic() + delay_ms / 1000.0, invoke))
|
||||||
|
|
||||||
|
|
||||||
|
def _pump_ui_queue() -> None:
|
||||||
|
root = _ctk_root
|
||||||
|
if root is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
deferred = []
|
||||||
|
for _ in range(32):
|
||||||
|
try:
|
||||||
|
due_at, callback = _ui_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
if due_at <= now:
|
||||||
|
callback()
|
||||||
|
else:
|
||||||
|
deferred.append((due_at, callback))
|
||||||
|
for item in deferred:
|
||||||
|
_ui_queue.put(item)
|
||||||
|
|
||||||
|
try:
|
||||||
|
root.after(20, _pump_ui_queue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _messagebox(kind: str, text: str, title: str) -> Any:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
result: list[Any] = []
|
||||||
|
done = threading.Event()
|
||||||
|
|
||||||
|
def show() -> None:
|
||||||
|
parent = None
|
||||||
|
try:
|
||||||
|
_activate_app()
|
||||||
|
_hide_ctk_root()
|
||||||
|
parent = tk.Toplevel(_ctk_root)
|
||||||
|
parent.withdraw()
|
||||||
|
result.append(getattr(messagebox, kind)(title, text, parent=parent))
|
||||||
|
finally:
|
||||||
|
if parent is not None:
|
||||||
|
try:
|
||||||
|
parent.destroy()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
_hide_ctk_root()
|
||||||
|
if _ctk_root is not None:
|
||||||
|
try:
|
||||||
|
_ctk_root.after_idle(_hide_ctk_root)
|
||||||
|
_ctk_root.after(50, _hide_ctk_root)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
if threading.current_thread() is threading.main_thread():
|
||||||
|
show()
|
||||||
|
elif _ctk_root is not None:
|
||||||
|
_dispatch(show)
|
||||||
|
done.wait()
|
||||||
|
return result[0] if result else False
|
||||||
|
|
||||||
|
|
||||||
|
def _standalone_info(text: str) -> None:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
messagebox.showinfo(t("app.name"), text, parent=root)
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||||
|
_messagebox("showerror", text, title or t("app.error_title"))
|
||||||
|
|
||||||
|
|
||||||
|
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||||
|
_messagebox("showinfo", text, title or t("app.name"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||||
|
return bool(_messagebox("askyesno", text, title or t("app.name")))
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_tray_menu() -> None:
|
||||||
|
if _tray_icon is None:
|
||||||
|
return
|
||||||
|
_tray_icon.menu = _build_menu()
|
||||||
|
try:
|
||||||
|
_tray_icon.update_menu()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to refresh tray menu: %s", repr(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_open_in_telegram(icon=None, item=None) -> None:
|
||||||
|
url = tg_proxy_url(_config)
|
||||||
|
log.info("Opening %s", url)
|
||||||
|
try:
|
||||||
|
if subprocess.call(["open", url]) != 0:
|
||||||
|
raise OSError("open command failed")
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
if not webbrowser.open(url):
|
||||||
|
raise OSError("webbrowser.open returned False")
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
if pyperclip is not None:
|
||||||
|
pyperclip.copy(url)
|
||||||
|
else:
|
||||||
|
subprocess.run(["pbcopy"], input=url.encode(), check=True)
|
||||||
|
_show_info(t("dialog.open_tg_fail_clipboard", url=url))
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_copy_link(icon=None, item=None) -> None:
|
||||||
|
url = tg_proxy_url(_config)
|
||||||
|
try:
|
||||||
|
if pyperclip is not None:
|
||||||
|
pyperclip.copy(url)
|
||||||
|
else:
|
||||||
|
subprocess.run(["pbcopy"], input=url.encode(), check=True)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_restart(icon=None, item=None) -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: restart_proxy(_config, _show_error),
|
||||||
|
daemon=True,
|
||||||
|
name="proxy-restart",
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_edit_config(icon=None, item=None) -> None:
|
||||||
|
log.info("Settings requested")
|
||||||
|
_dispatch(_edit_config_dialog, delay_ms=300)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_open_logs(icon=None, item=None) -> None:
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
try:
|
||||||
|
subprocess.run(["open", str(LOG_FILE)], check=True)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
_show_error(t("dialog.log_open_fail", error=exc))
|
||||||
|
else:
|
||||||
|
_show_info(t("dialog.log_not_found"))
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_exit() -> None:
|
||||||
|
global _exiting
|
||||||
|
if _exiting:
|
||||||
|
return
|
||||||
|
_exiting = True
|
||||||
|
log.info("User requested exit")
|
||||||
|
if _tray_icon is not None:
|
||||||
|
try:
|
||||||
|
_tray_icon.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to stop tray icon: %s", repr(exc))
|
||||||
|
if _ctk_root is not None:
|
||||||
|
_ctk_root.quit()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_exit(icon=None, item=None) -> None:
|
||||||
|
_dispatch(_finish_exit)
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_config_dialog() -> None:
|
||||||
|
global _settings_window
|
||||||
|
if _settings_window is not None:
|
||||||
|
try:
|
||||||
|
if _settings_window.winfo_exists():
|
||||||
|
_activate_app()
|
||||||
|
_settings_window.lift()
|
||||||
|
_settings_window.focus_force()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to reuse settings window: %s", repr(exc))
|
||||||
|
_settings_window = None
|
||||||
|
|
||||||
|
log.info("Creating settings window")
|
||||||
|
cfg = dict(_config)
|
||||||
|
theme = ctk_theme_for_platform()
|
||||||
|
width, height = CONFIG_DIALOG_SIZE
|
||||||
|
root = create_ctk_toplevel(
|
||||||
|
ctk,
|
||||||
|
title=t("app.settings_title"),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
theme=theme,
|
||||||
|
topmost=False,
|
||||||
|
after_create=lambda window: _activate_app(),
|
||||||
|
)
|
||||||
|
_settings_window = root
|
||||||
|
log.info("Settings window created")
|
||||||
|
frame_pad_x, frame_pad_y = CONFIG_DIALOG_FRAME_PAD
|
||||||
|
frame = main_content_frame(
|
||||||
|
ctk,
|
||||||
|
root,
|
||||||
|
theme,
|
||||||
|
padx=frame_pad_x,
|
||||||
|
pady=frame_pad_y,
|
||||||
|
)
|
||||||
|
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
||||||
|
log.info("Building settings form")
|
||||||
|
widgets = install_tray_config_form(
|
||||||
|
ctk,
|
||||||
|
scroll,
|
||||||
|
theme,
|
||||||
|
cfg,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
show_autostart=False,
|
||||||
|
)
|
||||||
|
log.info("Settings form built")
|
||||||
|
|
||||||
|
def finish() -> None:
|
||||||
|
global _settings_window
|
||||||
|
root.destroy()
|
||||||
|
_settings_window = None
|
||||||
|
_hide_ctk_root()
|
||||||
|
|
||||||
|
dialog = SettingsDialog(
|
||||||
|
ctk=ctk, root=root, widgets=widgets, config=_config,
|
||||||
|
defaults=DEFAULT_CONFIG, persist=save_config,
|
||||||
|
refresh_menu=_refresh_tray_menu, finish=finish,
|
||||||
|
restart=lambda config: restart_proxy(config, _show_error),
|
||||||
|
)
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||||
|
install_tray_config_buttons(
|
||||||
|
ctk,
|
||||||
|
footer,
|
||||||
|
theme,
|
||||||
|
on_save=dialog.save,
|
||||||
|
on_cancel=dialog.cancel,
|
||||||
|
)
|
||||||
|
_activate_app()
|
||||||
|
log.info("Settings window ready")
|
||||||
|
|
||||||
|
|
||||||
|
def _show_first_run() -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
if FIRST_RUN_MARKER.exists():
|
||||||
|
check_ipv6_warning(_show_info)
|
||||||
|
return
|
||||||
|
|
||||||
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
|
theme = ctk_theme_for_platform()
|
||||||
|
width, height = FIRST_RUN_SIZE
|
||||||
|
root = create_ctk_toplevel(
|
||||||
|
ctk,
|
||||||
|
title=t("app.name"),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
theme=theme,
|
||||||
|
topmost=False,
|
||||||
|
after_create=lambda window: _activate_app(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def done(open_telegram: bool) -> None:
|
||||||
|
FIRST_RUN_MARKER.touch()
|
||||||
|
root.destroy()
|
||||||
|
_hide_ctk_root()
|
||||||
|
if open_telegram:
|
||||||
|
_on_open_in_telegram()
|
||||||
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", lambda: done(False))
|
||||||
|
populate_first_run_window(
|
||||||
|
ctk,
|
||||||
|
root,
|
||||||
|
theme,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
secret=secret,
|
||||||
|
on_done=done,
|
||||||
|
)
|
||||||
|
_activate_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_menu():
|
||||||
|
if pystray is None:
|
||||||
|
return None
|
||||||
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
link_host = get_link_host(host)
|
||||||
|
return pystray.Menu(
|
||||||
|
pystray.MenuItem(
|
||||||
|
t("tray.open_telegram", host=link_host, port=port),
|
||||||
|
_on_open_in_telegram,
|
||||||
|
default=True,
|
||||||
|
),
|
||||||
|
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||||
|
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||||
|
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _initialize_gui() -> bool:
|
||||||
|
global _ctk_root, _ns_app
|
||||||
|
if ctk is None or pystray is None or Image is None or NSApplication is None:
|
||||||
|
return False
|
||||||
|
install_tkinter_variable_del_guard()
|
||||||
|
apply_ctk_appearance(ctk, _config.get("appearance", "auto"))
|
||||||
|
_ctk_root = ctk.CTk()
|
||||||
|
_ctk_root.title(t("app.name"))
|
||||||
|
_ctk_root.geometry("1x1+0+0")
|
||||||
|
try:
|
||||||
|
_ctk_root.attributes("-alpha", 0.0)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to make CTk root transparent: %s", repr(exc))
|
||||||
|
_ctk_root.withdraw()
|
||||||
|
_ctk_root.after(20, _pump_ui_queue)
|
||||||
|
_ns_app = NSApplication.sharedApplication()
|
||||||
|
if NSApplicationActivationPolicyAccessory is not None:
|
||||||
|
_ns_app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_crash_log() -> None:
|
||||||
|
global _crash_log
|
||||||
|
try:
|
||||||
|
ensure_dirs()
|
||||||
|
_crash_log = open(APP_DIR / "crash.log", "a", encoding="utf-8", buffering=1)
|
||||||
|
faulthandler.enable(file=_crash_log, all_threads=True)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Failed to enable crash log: %s", repr(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def run_tray() -> None:
|
||||||
|
global _tray_icon, _config
|
||||||
|
_config = load_config()
|
||||||
|
bootstrap(_config)
|
||||||
|
_enable_crash_log()
|
||||||
|
|
||||||
|
if not _initialize_gui():
|
||||||
|
log.error("pystray, Pillow, customtkinter or AppKit not installed; running in console mode")
|
||||||
|
start_proxy(_config, _show_error)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
stop_proxy()
|
||||||
|
return
|
||||||
|
|
||||||
|
start_proxy(_config, _show_error)
|
||||||
|
_tray_icon = pystray.Icon(
|
||||||
|
APP_NAME,
|
||||||
|
load_icon(),
|
||||||
|
t("app.name"),
|
||||||
|
menu=_build_menu(),
|
||||||
|
darwin_nsapplication=_ns_app,
|
||||||
|
)
|
||||||
|
_tray_icon.run_detached()
|
||||||
|
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
|
||||||
|
_ctk_root.after(0, _show_first_run)
|
||||||
|
log.info("Tray icon running")
|
||||||
|
_ctk_root.mainloop()
|
||||||
|
stop_proxy()
|
||||||
|
if _ctk_root is not None:
|
||||||
|
_ctk_root.destroy()
|
||||||
|
log.info("Tray app exited")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not acquire_lock():
|
||||||
|
_standalone_info(t("dialog.already_running"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
run_tray()
|
||||||
|
finally:
|
||||||
|
release_lock()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_PATH="${1:?Usage: build_dmg.sh <App.app> <Volume Name> <output.dmg> [assets_dir]}"
|
||||||
|
VOL_NAME="${2:?missing volume name}"
|
||||||
|
OUT_DMG="${3:?missing output dmg path}"
|
||||||
|
ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}"
|
||||||
|
|
||||||
|
WIN_W=660
|
||||||
|
WIN_H=440
|
||||||
|
ICON_SIZE=128
|
||||||
|
APP_X=145
|
||||||
|
APPS_X=515
|
||||||
|
ICON_Y=220
|
||||||
|
|
||||||
|
APP_NAME="$(basename "$APP_PATH")"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
STAGE="$WORK/stage"
|
||||||
|
RW_DMG="$WORK/rw.dmg"
|
||||||
|
MOUNT="/Volumes/$VOL_NAME"
|
||||||
|
DEVICE=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$DEVICE" ]; then
|
||||||
|
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/.background"
|
||||||
|
cp -R "$APP_PATH" "$STAGE/"
|
||||||
|
ln -s /Applications "$STAGE/Applications"
|
||||||
|
|
||||||
|
tiffutil -cathidpicheck \
|
||||||
|
"$ASSETS_DIR/background-light.png" \
|
||||||
|
"$ASSETS_DIR/background-light@2x.png" \
|
||||||
|
-out "$STAGE/.background/background.tiff"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-volname "$VOL_NAME" \
|
||||||
|
-srcfolder "$STAGE" \
|
||||||
|
-fs HFS+ \
|
||||||
|
-format UDRW \
|
||||||
|
-ov \
|
||||||
|
"$RW_DMG"
|
||||||
|
|
||||||
|
DEVICE="$(hdiutil attach \
|
||||||
|
-readwrite \
|
||||||
|
-noverify \
|
||||||
|
-noautoopen \
|
||||||
|
-mountpoint "$MOUNT" \
|
||||||
|
"$RW_DMG" \
|
||||||
|
| awk '/^\/dev\// { print $1; exit }')"
|
||||||
|
test -n "$DEVICE"
|
||||||
|
test -d "$MOUNT/$APP_NAME"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
osascript <<APPLESCRIPT
|
||||||
|
tell application "Finder"
|
||||||
|
tell disk "$VOL_NAME"
|
||||||
|
open
|
||||||
|
set current view of container window to icon view
|
||||||
|
set toolbar visible of container window to false
|
||||||
|
set statusbar visible of container window to false
|
||||||
|
set the bounds of container window to {200, 140, 200 + $WIN_W, 140 + $WIN_H}
|
||||||
|
set theViewOptions to the icon view options of container window
|
||||||
|
set arrangement of theViewOptions to not arranged
|
||||||
|
set icon size of theViewOptions to $ICON_SIZE
|
||||||
|
set text size of theViewOptions to 13
|
||||||
|
set background picture of theViewOptions to file ".background:background.tiff"
|
||||||
|
set position of item "$APP_NAME" of container window to {$APP_X, $ICON_Y}
|
||||||
|
set position of item "Applications" of container window to {$APPS_X, $ICON_Y}
|
||||||
|
close
|
||||||
|
open
|
||||||
|
update
|
||||||
|
delay 2
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
APPLESCRIPT
|
||||||
|
|
||||||
|
SetFile -a C "$MOUNT" 2>/dev/null || true
|
||||||
|
sync
|
||||||
|
|
||||||
|
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \
|
||||||
|
|| { sleep 3; hdiutil detach "$DEVICE" -force; }
|
||||||
|
DEVICE=""
|
||||||
|
|
||||||
|
rm -f "$OUT_DMG"
|
||||||
|
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG"
|
||||||
|
|
||||||
|
echo "Created $OUT_DMG"
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
# customtkinter ships JSON themes + assets that must be bundled
|
||||||
|
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')
|
||||||
|
|
||||||
|
appindicator_binaries = [
|
||||||
|
(path, '.')
|
||||||
|
for pattern in ('/usr/lib/*/libappindicator3.so.1',
|
||||||
|
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
|
||||||
|
for path in glob.glob(pattern)
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||||
|
pathex=[],
|
||||||
|
binaries=appindicator_binaries,
|
||||||
|
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||||
|
hiddenimports=[
|
||||||
|
'httpx',
|
||||||
|
'h2',
|
||||||
|
'pystray._appindicator',
|
||||||
|
'PIL._tkinter_finder',
|
||||||
|
'customtkinter',
|
||||||
|
'cryptography.hazmat.primitives.ciphers',
|
||||||
|
'cryptography.hazmat.primitives.ciphers.algorithms',
|
||||||
|
'cryptography.hazmat.primitives.ciphers.modes',
|
||||||
|
'cryptography.hazmat.backends.openssl',
|
||||||
|
'gi',
|
||||||
|
'gi.repository.GLib',
|
||||||
|
'gi.repository.GObject',
|
||||||
|
'gi.repository.Gtk',
|
||||||
|
'gi.repository.Gdk',
|
||||||
|
'gi.repository.DBus',
|
||||||
|
'gi.repository.AppIndicator3',
|
||||||
|
'gi.repository.AyatanaAppIndicator3',
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={
|
||||||
|
'gi': {
|
||||||
|
'icons': [],
|
||||||
|
'themes': [],
|
||||||
|
'languages': ['en', 'ru'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
|
noarchive=False,
|
||||||
|
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 = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
|
if os.path.exists(icon_path):
|
||||||
|
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='TgWsProxy',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=True,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
|
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(
|
||||||
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||||
|
hiddenimports=[
|
||||||
|
'httpx',
|
||||||
|
'h2',
|
||||||
|
'tkinter',
|
||||||
|
'customtkinter',
|
||||||
|
'pystray._darwin',
|
||||||
|
'PIL._tkinter_finder',
|
||||||
|
'objc',
|
||||||
|
'Foundation',
|
||||||
|
'AppKit',
|
||||||
|
'PyObjCTools',
|
||||||
|
'PyObjCTools.MachSignals',
|
||||||
|
'cryptography.hazmat.primitives.ciphers',
|
||||||
|
'cryptography.hazmat.primitives.ciphers.algorithms',
|
||||||
|
'cryptography.hazmat.primitives.ciphers.modes',
|
||||||
|
'cryptography.hazmat.backends.openssl',
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
],
|
||||||
|
noarchive=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
||||||
|
if not os.path.exists(icon_path):
|
||||||
|
icon_path = None
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='TgWsProxy',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
console=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch='universal2',
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='TgWsProxy',
|
||||||
|
)
|
||||||
|
|
||||||
|
app = BUNDLE(
|
||||||
|
coll,
|
||||||
|
name='TG WS Proxy.app',
|
||||||
|
icon=icon_path,
|
||||||
|
bundle_identifier='com.tgwsproxy.app',
|
||||||
|
info_plist={
|
||||||
|
'CFBundleName': 'TG WS Proxy',
|
||||||
|
'CFBundleDisplayName': 'TG WS Proxy',
|
||||||
|
'CFBundleShortVersionString': '1.0.0',
|
||||||
|
'CFBundleVersion': '1.0.0',
|
||||||
|
'LSMinimumSystemVersion': '10.15',
|
||||||
|
'LSUIElement': True,
|
||||||
|
'NSHighResolutionCapable': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# UTF-8
|
||||||
|
#
|
||||||
|
# For more details about fixed file info 'ffi' see:
|
||||||
|
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=(1, 11, 0, 0),
|
||||||
|
prodvers=(1, 11, 0, 0),
|
||||||
|
mask=0x3f,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
u'040904B0',
|
||||||
|
[
|
||||||
|
StringStruct(u'CompanyName', u'Flowseal'),
|
||||||
|
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||||
|
StringStruct(u'FileVersion', u'1.11.0.0'),
|
||||||
|
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||||
|
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||||
|
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||||
|
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||||
|
StringStruct(u'ProductVersion', u'1.11.0.0'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
@@ -26,14 +33,29 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'PIL._avif',
|
||||||
|
'PIL._webp',
|
||||||
|
'PIL._imagingtk',
|
||||||
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PIL_EXCLUDE_PYDS = {
|
||||||
|
'_avif', '_webp', '_imagingtk',
|
||||||
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
|
}
|
||||||
|
a.binaries = [
|
||||||
|
(name, path, typ)
|
||||||
|
for name, path, typ in a.binaries
|
||||||
|
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||||
|
]
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||||
|
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||||
|
|
||||||
@@ -50,7 +72,7 @@ exe = EXE(
|
|||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
@@ -60,4 +82,5 @@ exe = EXE(
|
|||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=icon_path if os.path.exists(icon_path) else None,
|
icon=icon_path if os.path.exists(icon_path) else None,
|
||||||
|
version=version_path if os.path.exists(version_path) else None,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
|
from .utils import get_link_host, build_github_opener
|
||||||
|
|
||||||
|
__version__ = "1.11.0"
|
||||||
|
|
||||||
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
AES-CTR shim.
|
||||||
|
|
||||||
|
Prefers `cryptography` if available (desktop / Docker). Falls back to a
|
||||||
|
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
|
||||||
|
installing `cryptography` is painful (Entware on routers, embedded boxes
|
||||||
|
without a Rust toolchain). The public surface mimics the small subset of
|
||||||
|
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
|
||||||
|
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
|
||||||
|
Cipher, algorithms, modes,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
|
||||||
|
def _load_libcrypto():
|
||||||
|
name = ctypes.util.find_library("crypto")
|
||||||
|
candidates = []
|
||||||
|
if name:
|
||||||
|
candidates.append(name)
|
||||||
|
candidates += [
|
||||||
|
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
|
||||||
|
"libcrypto.so", "/opt/lib/libcrypto.so",
|
||||||
|
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
|
||||||
|
]
|
||||||
|
last_err = None
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
return ctypes.CDLL(c)
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
raise RuntimeError(
|
||||||
|
"libcrypto not found; install openssl-util or "
|
||||||
|
"`opkg install libopenssl`. Last error: %r" % last_err
|
||||||
|
)
|
||||||
|
|
||||||
|
_libcrypto = _load_libcrypto()
|
||||||
|
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
|
||||||
|
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
||||||
|
ctypes.c_char_p, ctypes.c_char_p,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
|
||||||
|
_libcrypto.EVP_EncryptUpdate.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
|
||||||
|
ctypes.c_char_p, ctypes.c_int,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
|
||||||
|
|
||||||
|
_EVP_BY_KEY = {
|
||||||
|
16: _libcrypto.EVP_aes_128_ctr,
|
||||||
|
24: _libcrypto.EVP_aes_192_ctr,
|
||||||
|
32: _libcrypto.EVP_aes_256_ctr,
|
||||||
|
}
|
||||||
|
|
||||||
|
class algorithms:
|
||||||
|
class AES:
|
||||||
|
__slots__ = ("key",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
if len(key) not in _EVP_BY_KEY:
|
||||||
|
raise ValueError("AES key must be 16/24/32 bytes")
|
||||||
|
self.key = bytes(key)
|
||||||
|
|
||||||
|
class modes:
|
||||||
|
class CTR:
|
||||||
|
__slots__ = ("iv",)
|
||||||
|
|
||||||
|
def __init__(self, iv: bytes):
|
||||||
|
if len(iv) != 16:
|
||||||
|
raise ValueError("CTR IV must be 16 bytes")
|
||||||
|
self.iv = bytes(iv)
|
||||||
|
|
||||||
|
class _CtrStream:
|
||||||
|
__slots__ = ("_ctx",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes, iv: bytes):
|
||||||
|
ctx = _libcrypto.EVP_CIPHER_CTX_new()
|
||||||
|
if not ctx:
|
||||||
|
raise RuntimeError("EVP_CIPHER_CTX_new failed")
|
||||||
|
self._ctx = ctx
|
||||||
|
evp = _EVP_BY_KEY[len(key)]()
|
||||||
|
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
raise RuntimeError("EVP_EncryptInit_ex failed")
|
||||||
|
|
||||||
|
def update(self, data: bytes) -> bytes:
|
||||||
|
if not data:
|
||||||
|
return b""
|
||||||
|
outlen = ctypes.c_int(0)
|
||||||
|
buf = ctypes.create_string_buffer(len(data) + 16)
|
||||||
|
if _libcrypto.EVP_EncryptUpdate(
|
||||||
|
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
|
||||||
|
) != 1:
|
||||||
|
raise RuntimeError("EVP_EncryptUpdate failed")
|
||||||
|
return buf.raw[:outlen.value]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
ctx = getattr(self, "_ctx", None)
|
||||||
|
if ctx:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
|
||||||
|
class Cipher:
|
||||||
|
__slots__ = ("_key", "_iv")
|
||||||
|
|
||||||
|
def __init__(self, algorithm, mode):
|
||||||
|
if not isinstance(algorithm, algorithms.AES):
|
||||||
|
raise TypeError("only AES is supported")
|
||||||
|
if not isinstance(mode, modes.CTR):
|
||||||
|
raise TypeError("only CTR mode is supported")
|
||||||
|
self._key = algorithm.key
|
||||||
|
self._iv = mode.iv
|
||||||
|
|
||||||
|
def encryptor(self) -> _CtrStream:
|
||||||
|
return _CtrStream(self._key, self._iv)
|
||||||
|
|
||||||
|
# CTR is symmetric — decryption == encryption with the same keystream.
|
||||||
|
decryptor = encryptor
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import random
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from typing import Dict, List, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
class _Balancer:
|
||||||
|
def __init__(self):
|
||||||
|
self.domains: List[str] = []
|
||||||
|
self._dc_to_domain: Dict[int, str] = {}
|
||||||
|
|
||||||
|
def update_domains_list(self, domains_list: List[str]) -> None:
|
||||||
|
if Counter(self.domains) == Counter(domains_list):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.domains = domains_list[:]
|
||||||
|
|
||||||
|
self._dc_to_domain = {
|
||||||
|
dc_id: random.choice(self.domains)
|
||||||
|
for dc_id in (1, 2, 3, 4, 5, 203)
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_domain_for_dc(self, dc_id: int, domain: str) -> bool:
|
||||||
|
if self._dc_to_domain.get(dc_id) == domain:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._dc_to_domain[dc_id] = domain
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
|
||||||
|
current_domain = self._dc_to_domain.get(dc_id)
|
||||||
|
if current_domain is not None:
|
||||||
|
yield current_domain
|
||||||
|
|
||||||
|
shuffled_domains = self.domains[:]
|
||||||
|
random.shuffle(shuffled_domains)
|
||||||
|
|
||||||
|
for domain in shuffled_domains:
|
||||||
|
if domain != current_domain:
|
||||||
|
yield domain
|
||||||
|
|
||||||
|
|
||||||
|
balancer = _Balancer()
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
from .stats import stats
|
||||||
|
from .balancer import balancer
|
||||||
|
from .config import proxy_config
|
||||||
|
from .raw_websocket import RawWebSocket
|
||||||
|
from .pool import cf_worker_pool
|
||||||
|
from ._aes import Cipher, algorithms, modes
|
||||||
|
from .network_debug import WsActivity
|
||||||
|
from .cf_h2 import bridge_h2
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoCtx:
|
||||||
|
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
|
||||||
|
|
||||||
|
def __init__(self, clt_dec, clt_enc, tg_enc, tg_dec):
|
||||||
|
self.clt_dec = clt_dec # decrypt from client
|
||||||
|
self.clt_enc = clt_enc # encrypt to client
|
||||||
|
self.tg_enc = tg_enc # encrypt to telegram
|
||||||
|
self.tg_dec = tg_dec # decrypt from telegram
|
||||||
|
|
||||||
|
|
||||||
|
class MsgSplitter:
|
||||||
|
"""
|
||||||
|
Splits TCP stream data into individual MTProto transport packets
|
||||||
|
so each can be sent as a separate WS frame.
|
||||||
|
"""
|
||||||
|
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||||
|
|
||||||
|
def __init__(self, relay_init: bytes, proto_int: int):
|
||||||
|
cipher = Cipher(algorithms.AES(relay_init[8:40]),
|
||||||
|
modes.CTR(relay_init[40:56]))
|
||||||
|
self._dec = cipher.encryptor()
|
||||||
|
self._dec.update(ZERO_64)
|
||||||
|
self._proto = proto_int
|
||||||
|
self._cipher_buf = bytearray()
|
||||||
|
self._plain_buf = bytearray()
|
||||||
|
self._disabled = False
|
||||||
|
|
||||||
|
def split(self, chunk: bytes) -> List[bytes]:
|
||||||
|
if not chunk:
|
||||||
|
return []
|
||||||
|
if self._disabled:
|
||||||
|
return [chunk]
|
||||||
|
|
||||||
|
self._cipher_buf.extend(chunk)
|
||||||
|
self._plain_buf.extend(self._dec.update(chunk))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
offset = 0
|
||||||
|
buf_len = len(self._cipher_buf)
|
||||||
|
# Walk the buffer with an offset instead of deleting each packet from
|
||||||
|
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
||||||
|
# so a chunk holding many small packets degrades to O(N^2); a single
|
||||||
|
# trailing del keeps splitting O(N).
|
||||||
|
while offset < buf_len:
|
||||||
|
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||||
|
if packet_len is None:
|
||||||
|
break
|
||||||
|
if packet_len <= 0:
|
||||||
|
parts.append(bytes(self._cipher_buf[offset:]))
|
||||||
|
offset = buf_len
|
||||||
|
self._disabled = True
|
||||||
|
break
|
||||||
|
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||||
|
offset += packet_len
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
del self._cipher_buf[:offset]
|
||||||
|
del self._plain_buf[:offset]
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def flush(self) -> List[bytes]:
|
||||||
|
if not self._cipher_buf:
|
||||||
|
return []
|
||||||
|
tail = bytes(self._cipher_buf)
|
||||||
|
self._cipher_buf.clear()
|
||||||
|
self._plain_buf.clear()
|
||||||
|
return [tail]
|
||||||
|
|
||||||
|
def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
|
if avail <= 0:
|
||||||
|
return None
|
||||||
|
if self._proto == PROTO_ABRIDGED_INT:
|
||||||
|
return self._next_abridged_len(offset, avail)
|
||||||
|
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT):
|
||||||
|
return self._next_intermediate_len(offset, avail)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
|
first = self._plain_buf[offset]
|
||||||
|
if first in (0x7F, 0xFF):
|
||||||
|
if avail < 4:
|
||||||
|
return None
|
||||||
|
payload_len = int.from_bytes(
|
||||||
|
self._plain_buf[offset + 1:offset + 4], 'little') * 4
|
||||||
|
header_len = 4
|
||||||
|
else:
|
||||||
|
payload_len = (first & 0x7F) * 4
|
||||||
|
header_len = 1
|
||||||
|
if payload_len <= 0:
|
||||||
|
return 0
|
||||||
|
packet_len = header_len + payload_len
|
||||||
|
if avail < packet_len:
|
||||||
|
return None
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
|
if avail < 4:
|
||||||
|
return None
|
||||||
|
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
|
||||||
|
if payload_len <= 0:
|
||||||
|
return 0
|
||||||
|
packet_len = 4 + payload_len
|
||||||
|
if avail < packet_len:
|
||||||
|
return None
|
||||||
|
return packet_len
|
||||||
|
|
||||||
|
|
||||||
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
|
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
|
||||||
|
ctx: CryptoCtx, splitter=None, *, h2_pool=None, proto_tag=None):
|
||||||
|
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
|
||||||
|
fallback_dst = ip_table.get(dc)
|
||||||
|
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
|
||||||
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
|
methods: List[str] = []
|
||||||
|
|
||||||
|
if worker_domains and fallback_dst:
|
||||||
|
methods.append('cf_worker')
|
||||||
|
if use_cf:
|
||||||
|
methods.append('cf')
|
||||||
|
if fallback_dst:
|
||||||
|
methods.append('tcp')
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
if method == 'cf_worker' and fallback_dst:
|
||||||
|
ok = await _cfproxy_worker_fallback(
|
||||||
|
reader, writer, relay_init, label, ctx,
|
||||||
|
dc=dc, is_test_dc=is_test_dc, is_media=is_media,
|
||||||
|
fallback_dst=fallback_dst, splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
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(
|
||||||
|
reader, writer, relay_init, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
elif method == 'tcp' and fallback_dst:
|
||||||
|
ok = await _tcp_fallback(
|
||||||
|
reader, writer, fallback_dst, 443,
|
||||||
|
relay_init, label, ctx)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc: int, is_test_dc: bool, is_media: bool,
|
||||||
|
fallback_dst: str,
|
||||||
|
splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
if not worker_domains:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pooled = None if is_test_dc else await cf_worker_pool.get(
|
||||||
|
dc, fallback_dst, worker_domains)
|
||||||
|
if pooled:
|
||||||
|
ws, worker_domain = pooled
|
||||||
|
log.info("[%s] DC%d%s -> CF worker pool hit via %s for %s",
|
||||||
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
else:
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
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",
|
||||||
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
|
timeout=10.0, path=path,
|
||||||
|
secure=not proxy_config.disable_secure)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
cf_worker_pool.report_failure(worker_domain, exc)
|
||||||
|
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||||
|
label, dc, media_tag, worker_domain, repr(exc))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc: int, is_media: bool,
|
||||||
|
splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
ws = None
|
||||||
|
chosen_domain = None
|
||||||
|
|
||||||
|
log.info("[%s] DC%d%s -> trying CF proxy",
|
||||||
|
label, dc, media_tag)
|
||||||
|
connect_started = time.monotonic()
|
||||||
|
|
||||||
|
for base_domain in balancer.get_domains_for_dc(dc):
|
||||||
|
domain = f'kws{dc}.{base_domain}'
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(domain, domain, timeout=10.0,
|
||||||
|
secure=not proxy_config.disable_secure)
|
||||||
|
chosen_domain = base_domain
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] DC%d%s CF proxy failed: %s",
|
||||||
|
label, dc, media_tag, repr(exc))
|
||||||
|
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
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):
|
||||||
|
log.info("[%s] Switched active CF domain", label)
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
||||||
|
key = (dst, port)
|
||||||
|
if key in _tcp_connecting or time.monotonic() < _tcp_retry_after.get(key, 0):
|
||||||
|
log.debug("[%s] TCP fallback to %s:%d skipped (backoff or connecting)",
|
||||||
|
label, dst, port)
|
||||||
|
return False
|
||||||
|
|
||||||
|
_tcp_connecting.add(key)
|
||||||
|
rw = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc=None, is_media=False,
|
||||||
|
splitter: Optional[MsgSplitter] = None):
|
||||||
|
"""
|
||||||
|
Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
|
||||||
|
client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS
|
||||||
|
WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP
|
||||||
|
"""
|
||||||
|
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
|
||||||
|
|
||||||
|
up_bytes = 0
|
||||||
|
down_bytes = 0
|
||||||
|
up_packets = 0
|
||||||
|
down_packets = 0
|
||||||
|
start_time = asyncio.get_running_loop().time()
|
||||||
|
close_reason = 'normal'
|
||||||
|
activity = WsActivity(label, dc_tag) if log.isEnabledFor(logging.DEBUG) else None
|
||||||
|
|
||||||
|
async def tcp_to_ws():
|
||||||
|
nonlocal up_bytes, up_packets, close_reason
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
if splitter:
|
||||||
|
tail = splitter.flush()
|
||||||
|
if tail:
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
|
await ws.send(tail[0])
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(len(tail[0]))
|
||||||
|
break
|
||||||
|
n = len(chunk)
|
||||||
|
if activity is not None:
|
||||||
|
activity.input(n)
|
||||||
|
stats.bytes_up += n
|
||||||
|
up_bytes += n
|
||||||
|
up_packets += 1
|
||||||
|
plain = ctx.clt_dec.update(chunk)
|
||||||
|
chunk = ctx.tg_enc.update(plain)
|
||||||
|
if splitter:
|
||||||
|
parts = splitter.split(chunk)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
|
if len(parts) > 1:
|
||||||
|
await ws.send_batch(parts)
|
||||||
|
else:
|
||||||
|
await ws.send(parts[0])
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(sum(map(len, parts)))
|
||||||
|
else:
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
|
await ws.send(chunk)
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(len(chunk))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}"
|
||||||
|
except Exception as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}: {e}"
|
||||||
|
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||||
|
|
||||||
|
async def ws_to_tcp():
|
||||||
|
nonlocal down_bytes, down_packets, close_reason
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.recv()
|
||||||
|
if data is None:
|
||||||
|
if close_reason == 'normal':
|
||||||
|
close_reason = 'upstream: ws_close'
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
if activity is not None:
|
||||||
|
activity.received(n)
|
||||||
|
stats.bytes_down += n
|
||||||
|
down_bytes += n
|
||||||
|
down_packets += 1
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
if activity is not None:
|
||||||
|
activity.delivered(n)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
close_reason = f"upstream: {type(e).__name__}"
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
close_reason = 'upstream: tcp_reset'
|
||||||
|
except Exception as e:
|
||||||
|
close_reason = f"upstream: {type(e).__name__}: {e}"
|
||||||
|
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
|
asyncio.create_task(ws_to_tcp())]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
elapsed = asyncio.get_running_loop().time() - start_time
|
||||||
|
if activity is not None:
|
||||||
|
activity.close()
|
||||||
|
log.info("[%s] %s WS session closed (%s): "
|
||||||
|
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||||
|
label, dc_tag, close_reason,
|
||||||
|
human_bytes(up_bytes), up_packets,
|
||||||
|
human_bytes(down_bytes), down_packets,
|
||||||
|
elapsed)
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
|
||||||
|
label, ctx: CryptoCtx):
|
||||||
|
"""Bidirectional TCP <-> TCP with re-encryption."""
|
||||||
|
|
||||||
|
async def forward(src, dst_w, is_up):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await src.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
n = len(data)
|
||||||
|
if is_up:
|
||||||
|
stats.bytes_up += n
|
||||||
|
plain = ctx.clt_dec.update(data)
|
||||||
|
data = ctx.tg_enc.update(plain)
|
||||||
|
else:
|
||||||
|
stats.bytes_down += n
|
||||||
|
plain = ctx.tg_dec.update(data)
|
||||||
|
data = ctx.clt_enc.update(plain)
|
||||||
|
dst_w.write(data)
|
||||||
|
await dst_w.drain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("[%s] forward ended: %s", label, e)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(forward(reader, remote_writer, True)),
|
||||||
|
asyncio.create_task(forward(remote_reader, writer, False)),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
for w in (writer, remote_writer):
|
||||||
|
try:
|
||||||
|
w.close()
|
||||||
|
await w.wait_closed()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
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"""
|
||||||
|
|
||||||
|
|
||||||
|
@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.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
|
||||||
|
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 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; '
|
||||||
|
'closing native channel, reconnect may select another domain',
|
||||||
|
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc)
|
||||||
|
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 response.status_code in (403, 429) or (response.status_code >= 500
|
||||||
|
and response.status_code != 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 != 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:
|
||||||
|
log.warning('H2 lane=%d channel=%d request=%d MTProto transport error=%d',
|
||||||
|
self.lane_id, channel_id, request_id, struct.unpack('<i', content)[0])
|
||||||
|
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 _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:
|
||||||
|
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
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 (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:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
await channel.close()
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
import socket as _socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
from .balancer import balancer
|
||||||
|
from .utils import build_github_opener
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
CFPROXY_DOMAINS_URL = (
|
||||||
|
"https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main"
|
||||||
|
"/.github/cfproxy-domains.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CFPROXY_ENC: List[str] = [
|
||||||
|
'virkgj.com',
|
||||||
|
'vmmzovy.com',
|
||||||
|
'mkuosckvso.com',
|
||||||
|
'zaewayzmplad.com',
|
||||||
|
'twdmbzcm.com',
|
||||||
|
'awzwsldi.com',
|
||||||
|
'clngqrflngqin.com',
|
||||||
|
'tjacxbqtj.com',
|
||||||
|
'bxaxtxmrw.com',
|
||||||
|
'dmohrsgmohcrwb.com',
|
||||||
|
'vwbmtmoi.com',
|
||||||
|
'khgrre.com',
|
||||||
|
'ulihssf.com',
|
||||||
|
'tmhqsdqmfpmk.com',
|
||||||
|
'xwuwoqbm.com',
|
||||||
|
'orgcnunpj.com',
|
||||||
|
'zhkuldz.com',
|
||||||
|
'zypoljnslxa.com',
|
||||||
|
'efabnxaowuzs.com',
|
||||||
|
'zaftuzsftqdq.com'
|
||||||
|
]
|
||||||
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
|
|
||||||
|
def _dd(s: str) -> str:
|
||||||
|
"""Only for decoding CF proxy domains"""
|
||||||
|
if not s[-4:] == '.com':
|
||||||
|
return s
|
||||||
|
p, n = s[:-4], sum(c.isalpha() for c in s[:-4])
|
||||||
|
return ''.join(
|
||||||
|
chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65))
|
||||||
|
if c.isalpha() else c for c in p
|
||||||
|
) + _S
|
||||||
|
|
||||||
|
|
||||||
|
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
|
||||||
|
_CFPROXY_MIN_VALID_DOMAINS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProxyConfig:
|
||||||
|
port: int = 1443
|
||||||
|
host: str = '127.0.0.1'
|
||||||
|
secret: str = field(default_factory=lambda: os.urandom(16).hex())
|
||||||
|
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
|
||||||
|
buffer_size: int = 256 * 1024
|
||||||
|
pool_size: int = 4
|
||||||
|
fallback_cfproxy: bool = True
|
||||||
|
cfproxy_user_domains: List[str] = field(default_factory=list)
|
||||||
|
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||||
|
cfproxy_h2_media: bool = True
|
||||||
|
disable_secure: bool = False
|
||||||
|
fake_tls_domain: str = ''
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_domain_list(value) -> List[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
items = value.replace(',', ' ').replace(';', ' ').split()
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
items: List[str] = []
|
||||||
|
for entry in value:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
seen = set()
|
||||||
|
result: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
key = item.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_cfproxy_domain_list() -> List[str]:
|
||||||
|
try:
|
||||||
|
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||||
|
headers={'User-Agent': 'tg-ws-proxy'})
|
||||||
|
with build_github_opener().open(req, timeout=10) as resp:
|
||||||
|
text = resp.read().decode('utf-8', errors='replace')
|
||||||
|
encoded = [
|
||||||
|
line.strip() for line in text.splitlines()
|
||||||
|
if line.strip() and not line.startswith('#')
|
||||||
|
]
|
||||||
|
return [_dd(d) for d in encoded]
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_domain(domain: str) -> bool:
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
return False
|
||||||
|
if domain.startswith('.') or domain.endswith('.'):
|
||||||
|
return False
|
||||||
|
labels = domain.split('.')
|
||||||
|
if len(labels) < 2:
|
||||||
|
return False
|
||||||
|
for label in labels:
|
||||||
|
if not label or len(label) > 63:
|
||||||
|
return False
|
||||||
|
if label[0] == '-' or label[-1] == '-':
|
||||||
|
return False
|
||||||
|
if not all(ch.isalnum() or ch == '-' for ch in label):
|
||||||
|
return False
|
||||||
|
# TLD should contain letters and be at least 2 chars.
|
||||||
|
tld = labels[-1]
|
||||||
|
if len(tld) < 2 or not any(ch.isalpha() for ch in tld):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domain_pool(domains: List[str]) -> List[str]:
|
||||||
|
seen = set()
|
||||||
|
normalized: List[str] = []
|
||||||
|
for domain in domains:
|
||||||
|
item = domain.strip().lower()
|
||||||
|
if not _is_valid_domain(item):
|
||||||
|
continue
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_cfproxy_domains() -> None:
|
||||||
|
if proxy_config.cfproxy_user_domains:
|
||||||
|
return
|
||||||
|
|
||||||
|
fetched = _fetch_cfproxy_domain_list()
|
||||||
|
pool = _normalize_domain_pool(fetched)
|
||||||
|
if len(pool) >= _CFPROXY_MIN_VALID_DOMAINS:
|
||||||
|
balancer.update_domains_list(pool)
|
||||||
|
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool))
|
||||||
|
return
|
||||||
|
|
||||||
|
if fetched:
|
||||||
|
log.warning(
|
||||||
|
"Ignoring fetched CF proxy domains due to low-quality payload "
|
||||||
|
"(total=%d, valid=%d, required>=%d); keeping current domain pool",
|
||||||
|
len(fetched), len(pool), _CFPROXY_MIN_VALID_DOMAINS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"CF proxy domain refresh failed or empty response; "
|
||||||
|
"keeping current domain pool",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_refresh_stop: threading.Event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def start_cfproxy_domain_refresh() -> None:
|
||||||
|
global _refresh_stop
|
||||||
|
_refresh_stop.set()
|
||||||
|
_refresh_stop = threading.Event()
|
||||||
|
stop = _refresh_stop
|
||||||
|
|
||||||
|
balancer.update_domains_list(CFPROXY_DEFAULT_DOMAINS)
|
||||||
|
|
||||||
|
def _loop():
|
||||||
|
refresh_cfproxy_domains()
|
||||||
|
while not stop.wait(timeout=3600):
|
||||||
|
refresh_cfproxy_domains()
|
||||||
|
|
||||||
|
threading.Thread(target=_loop, daemon=True, name='cfproxy-domains-refresh').start()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
||||||
|
dc_redirects: Dict[int, str] = {}
|
||||||
|
for entry in dc_ip_list:
|
||||||
|
if ':' not in entry:
|
||||||
|
err = ValueError(
|
||||||
|
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
|
||||||
|
err.entry = entry
|
||||||
|
err.kind = "format"
|
||||||
|
raise err
|
||||||
|
dc_s, ip_s = entry.split(':', 1)
|
||||||
|
try:
|
||||||
|
dc_n = int(dc_s)
|
||||||
|
_socket.inet_pton(_socket.AF_INET, ip_s)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
||||||
|
err.entry = entry
|
||||||
|
err.kind = "invalid"
|
||||||
|
raise err from None
|
||||||
|
dc_redirects[dc_n] = ip_s
|
||||||
|
return dc_redirects
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from .stats import stats
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
TLS_RECORD_HANDSHAKE = 0x16
|
||||||
|
TLS_RECORD_CCS = 0x14
|
||||||
|
TLS_RECORD_APPDATA = 0x17
|
||||||
|
|
||||||
|
TLS_VERSION_10 = b'\x03\x01'
|
||||||
|
TLS_VERSION_12 = b'\x03\x03'
|
||||||
|
TLS_VERSION_13 = b'\x03\x04'
|
||||||
|
|
||||||
|
CLIENT_RANDOM_OFFSET = 11
|
||||||
|
CLIENT_RANDOM_LEN = 32
|
||||||
|
SESSION_ID_OFFSET = 44
|
||||||
|
SESSION_ID_LEN = 32
|
||||||
|
|
||||||
|
TIMESTAMP_TOLERANCE = 120
|
||||||
|
|
||||||
|
TLS_APPDATA_MAX = 16384
|
||||||
|
|
||||||
|
|
||||||
|
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
|
||||||
|
|
||||||
|
_SERVER_HELLO_TEMPLATE = bytearray(
|
||||||
|
b'\x16\x03\x03\x00\x7a'
|
||||||
|
b'\x02\x00\x00\x76'
|
||||||
|
b'\x03\x03'
|
||||||
|
+ b'\x00' * 32
|
||||||
|
+ b'\x20'
|
||||||
|
+ b'\x00' * 32
|
||||||
|
+ b'\x13\x01\x00'
|
||||||
|
+ b'\x00\x2e'
|
||||||
|
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
|
||||||
|
+ b'\x00' * 32
|
||||||
|
+ b'\x00\x2b\x00\x02\x03\x04'
|
||||||
|
)
|
||||||
|
|
||||||
|
_SH_RANDOM_OFF = 11
|
||||||
|
_SH_SESSID_OFF = 44
|
||||||
|
_SH_PUBKEY_OFF = 89
|
||||||
|
|
||||||
|
|
||||||
|
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
|
||||||
|
n = len(data)
|
||||||
|
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
|
||||||
|
if n < 43:
|
||||||
|
return None
|
||||||
|
if data[0] != TLS_RECORD_HANDSHAKE:
|
||||||
|
return None
|
||||||
|
if data[5] != 0x01:
|
||||||
|
return None
|
||||||
|
|
||||||
|
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
|
||||||
|
|
||||||
|
zeroed = bytearray(data)
|
||||||
|
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
|
||||||
|
|
||||||
|
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected[:28], client_random[:28]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
|
||||||
|
timestamp = struct.unpack('<I', ts_xor)[0]
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_id = b'\x00' * SESSION_ID_LEN
|
||||||
|
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
|
||||||
|
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
|
||||||
|
|
||||||
|
return client_random, session_id, timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
|
||||||
|
sh = bytearray(_SERVER_HELLO_TEMPLATE)
|
||||||
|
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
|
||||||
|
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
|
||||||
|
|
||||||
|
ccs = _CCS_FRAME
|
||||||
|
encrypted_size = random.randint(1900, 2100)
|
||||||
|
encrypted_data = os.urandom(encrypted_size)
|
||||||
|
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
|
||||||
|
|
||||||
|
response = bytes(sh) + ccs + app_record
|
||||||
|
|
||||||
|
hmac_input = client_random + response
|
||||||
|
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
final = bytearray(response)
|
||||||
|
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
|
||||||
|
|
||||||
|
return bytes(final)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_tls_record(data: bytes) -> bytes:
|
||||||
|
parts = []
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
chunk = data[offset:offset + TLS_APPDATA_MAX]
|
||||||
|
parts.append(
|
||||||
|
b'\x17\x03\x03'
|
||||||
|
+ struct.pack('>H', len(chunk))
|
||||||
|
+ chunk
|
||||||
|
)
|
||||||
|
offset += len(chunk)
|
||||||
|
return b''.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTlsStream:
|
||||||
|
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
|
||||||
|
|
||||||
|
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
|
self._reader = reader
|
||||||
|
self._writer = writer
|
||||||
|
self._read_buf = bytearray()
|
||||||
|
self._read_left = 0
|
||||||
|
|
||||||
|
async def readexactly(self, n: int) -> bytes:
|
||||||
|
while len(self._read_buf) < n:
|
||||||
|
payload = await self._read_tls_payload()
|
||||||
|
if not payload:
|
||||||
|
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
|
||||||
|
self._read_buf.extend(payload)
|
||||||
|
result = bytes(self._read_buf[:n])
|
||||||
|
del self._read_buf[:n]
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def read(self, n: int) -> bytes:
|
||||||
|
if self._read_buf:
|
||||||
|
chunk = bytes(self._read_buf[:n])
|
||||||
|
del self._read_buf[:n]
|
||||||
|
return chunk
|
||||||
|
payload = await self._read_tls_payload()
|
||||||
|
if not payload:
|
||||||
|
return b''
|
||||||
|
if len(payload) > n:
|
||||||
|
self._read_buf.extend(payload[n:])
|
||||||
|
return payload[:n]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _read_tls_payload(self) -> bytes:
|
||||||
|
if self._read_left > 0:
|
||||||
|
data = await self._reader.read(self._read_left)
|
||||||
|
if not data:
|
||||||
|
return b''
|
||||||
|
self._read_left -= len(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
while True:
|
||||||
|
hdr = await self._reader.readexactly(5)
|
||||||
|
rtype = hdr[0]
|
||||||
|
rec_len = struct.unpack('>H', hdr[3:5])[0]
|
||||||
|
|
||||||
|
if rtype == TLS_RECORD_CCS:
|
||||||
|
if rec_len > 0:
|
||||||
|
await self._reader.readexactly(rec_len)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rtype != TLS_RECORD_APPDATA:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
data = await self._reader.read(min(rec_len, 65536))
|
||||||
|
if not data:
|
||||||
|
return b''
|
||||||
|
remaining = rec_len - len(data)
|
||||||
|
if remaining > 0:
|
||||||
|
self._read_left = remaining
|
||||||
|
return data
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
self._writer.write(wrap_tls_record(data))
|
||||||
|
|
||||||
|
async def drain(self) -> None:
|
||||||
|
await self._writer.drain()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._writer.close()
|
||||||
|
|
||||||
|
async def wait_closed(self) -> None:
|
||||||
|
await self._writer.wait_closed()
|
||||||
|
|
||||||
|
def get_extra_info(self, name, default=None):
|
||||||
|
return self._writer.get_extra_info(name, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transport(self):
|
||||||
|
return self._writer.transport
|
||||||
|
|
||||||
|
def is_closing(self):
|
||||||
|
return self._writer.is_closing()
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
|
||||||
|
domain: str, label: str) -> None:
|
||||||
|
try:
|
||||||
|
up_reader, up_writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(domain, 443), timeout=10)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] masking: cannot connect to %s:443: %s",
|
||||||
|
label, domain, repr(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("[%s] masking -> %s:443", label, domain)
|
||||||
|
stats.connections_masked += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if initial_data:
|
||||||
|
up_writer.write(initial_data)
|
||||||
|
await up_writer.drain()
|
||||||
|
|
||||||
|
async def _relay(src, dst):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await src.read(16384)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
await dst.drain()
|
||||||
|
except (ConnectionResetError, BrokenPipeError, OSError,
|
||||||
|
asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
dst.close()
|
||||||
|
await dst.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
_relay(reader, up_writer),
|
||||||
|
_relay(up_reader, writer),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
up_writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -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]))
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from typing import Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
|
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||||
|
from .stats import stats
|
||||||
|
from .config import proxy_config
|
||||||
|
from .utils import ws_domains, DC_DEFAULT_IPS, WS_PATH, WS_PATH_TEST
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
|
||||||
|
class _WsPool:
|
||||||
|
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):
|
||||||
|
self._idle: Dict[Tuple[int, bool, bool], deque] = {}
|
||||||
|
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, *, is_test_dc: bool = False
|
||||||
|
) -> Optional[RawWebSocket]:
|
||||||
|
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()
|
||||||
|
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[key] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if self._is_stale(ws, created, now):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.pool_hits += 1
|
||||||
|
log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
|
||||||
|
dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
stats.pool_misses += 1
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _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):
|
||||||
|
if proxy_config.pool_size <= 0:
|
||||||
|
return
|
||||||
|
self._schedule_rotation(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):
|
||||||
|
dc, is_media, is_test_dc = key
|
||||||
|
tasks = []
|
||||||
|
adopted = set()
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(key, deque())
|
||||||
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
connected = 0
|
||||||
|
tasks = [asyncio.create_task(
|
||||||
|
self._connect_one(target_ip, domains,
|
||||||
|
WS_PATH_TEST if is_test_dc else WS_PATH))
|
||||||
|
for _ in range(needed)]
|
||||||
|
for t in asyncio.as_completed(tasks):
|
||||||
|
try:
|
||||||
|
ws = await t
|
||||||
|
if ws:
|
||||||
|
if self._refilling.get(key) is not asyncio.current_task():
|
||||||
|
return
|
||||||
|
bucket.append((ws, time.monotonic()))
|
||||||
|
adopted.add(ws)
|
||||||
|
connected += 1
|
||||||
|
except Exception as exc:
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _schedule_rotation(self, key, target_ip, domains):
|
||||||
|
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:
|
||||||
|
modes = (True, False) if self.try_fronting_first else (False, True)
|
||||||
|
for fronted in modes:
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(
|
||||||
|
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
|
||||||
|
self.try_fronting_first = fronted
|
||||||
|
if fronted:
|
||||||
|
stats.connections_fronting += 1
|
||||||
|
log.debug("WS pool connected %s%s via %s (fronting=%s)",
|
||||||
|
domain, path, target_ip, fronted)
|
||||||
|
return ws
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _quiet_close(self, ws):
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
for dc, target_ip in proxy_config.dc_redirects.items():
|
||||||
|
if target_ip is None:
|
||||||
|
continue
|
||||||
|
for is_media in (False, True):
|
||||||
|
domains = ws_domains(dc, is_media)
|
||||||
|
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))
|
||||||
|
|
||||||
|
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._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:
|
||||||
|
WS_POOL_MAX_AGE = 100.0
|
||||||
|
PER_DC_LIMIT = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle: Dict[int, deque] = {}
|
||||||
|
self._refilling: Set[int] = set()
|
||||||
|
self._exhausted_until: Dict[str, float] = {}
|
||||||
|
|
||||||
|
async def get(self, dc: int, fallback_dst: str,
|
||||||
|
worker_domains: List[str]
|
||||||
|
) -> Optional[Tuple[RawWebSocket, str]]:
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
bucket = self._idle.get(dc)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[dc] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created, worker_domain = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.cf_pool_hits += 1
|
||||||
|
log.debug(
|
||||||
|
"CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
|
||||||
|
dc, worker_domain, age, len(bucket))
|
||||||
|
self._schedule_refill(dc, fallback_dst, worker_domains)
|
||||||
|
return ws, worker_domain
|
||||||
|
|
||||||
|
stats.cf_pool_misses += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
||||||
|
if dc in self._refilling:
|
||||||
|
return
|
||||||
|
self._refilling.add(dc)
|
||||||
|
asyncio.create_task(self._refill(
|
||||||
|
dc, fallback_dst, list(worker_domains)))
|
||||||
|
|
||||||
|
async def _refill(self, dc, fallback_dst, worker_domains):
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(dc, deque())
|
||||||
|
target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
|
||||||
|
needed = target_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
for _ in range(needed):
|
||||||
|
connected = await self._connect_one(
|
||||||
|
worker_domains, fallback_dst, dc)
|
||||||
|
if connected is None:
|
||||||
|
break
|
||||||
|
ws, worker_domain = connected
|
||||||
|
bucket.append((ws, time.monotonic(), worker_domain))
|
||||||
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
|
dc, len(bucket))
|
||||||
|
finally:
|
||||||
|
self._refilling.discard(dc)
|
||||||
|
|
||||||
|
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
for worker_domain in self.available_domains(worker_domains):
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(
|
||||||
|
worker_domain, worker_domain, timeout=8, path=path,
|
||||||
|
secure=not proxy_config.disable_secure)
|
||||||
|
return ws, worker_domain
|
||||||
|
except Exception as exc:
|
||||||
|
self.report_failure(worker_domain, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def available_domains(self, worker_domains: List[str]) -> List[str]:
|
||||||
|
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:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
cf_fallbacks = {
|
||||||
|
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
|
||||||
|
if dc not in proxy_config.dc_redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
||||||
|
return
|
||||||
|
|
||||||
|
worker_domains = list(proxy_config.cfproxy_worker_domains)
|
||||||
|
for dc, fallback_dst in cf_fallbacks.items():
|
||||||
|
self._schedule_refill(dc, fallback_dst, worker_domains)
|
||||||
|
|
||||||
|
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._idle.clear()
|
||||||
|
self._refilling.clear()
|
||||||
|
self._exhausted_until.clear()
|
||||||
|
|
||||||
|
|
||||||
|
ws_pool = _WsPool()
|
||||||
|
cf_worker_pool = _CfWorkerPool()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import asyncio
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from .config import proxy_config
|
||||||
|
from .utils import create_ssl_context
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
|
||||||
|
_st_BB = struct.Struct('>BB')
|
||||||
|
_st_BBH = struct.Struct('>BBH')
|
||||||
|
_st_BBQ = struct.Struct('>BBQ')
|
||||||
|
_st_BB4s = struct.Struct('>BB4s')
|
||||||
|
_st_BBH4s = struct.Struct('>BBH4s')
|
||||||
|
_st_BBQ4s = struct.Struct('>BBQ4s')
|
||||||
|
_st_H = struct.Struct('>H')
|
||||||
|
_st_Q = struct.Struct('>Q')
|
||||||
|
|
||||||
|
_ssl_ctx = create_ssl_context()
|
||||||
|
_ssl_ctx_fronting = create_ssl_context(check_hostname=False)
|
||||||
|
|
||||||
|
class WsHandshakeError(Exception):
|
||||||
|
def __init__(self, status_code: int, status_line: str,
|
||||||
|
headers: Optional[dict] = None, location: Optional[str] = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.status_line = status_line
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.location = location
|
||||||
|
super().__init__(f"HTTP {status_code}: {status_line}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_redirect(self) -> bool:
|
||||||
|
return self.status_code in (301, 302, 303, 307, 308)
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_mask(data: bytes, mask: bytes) -> bytes:
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
n = len(data)
|
||||||
|
mask_rep = (mask * (n // 4 + 1))[:n]
|
||||||
|
return (int.from_bytes(data, 'big') ^
|
||||||
|
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
||||||
|
|
||||||
|
|
||||||
|
def set_sock_opts(transport, buffer_size):
|
||||||
|
sock = transport.get_extra_info('socket')
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, buffer_size)
|
||||||
|
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, buffer_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RawWebSocket:
|
||||||
|
__slots__ = ('reader', 'writer', '_closed', '_frag')
|
||||||
|
|
||||||
|
OP_CONT = 0x0
|
||||||
|
OP_BINARY = 0x2
|
||||||
|
OP_CLOSE = 0x8
|
||||||
|
OP_PING = 0x9
|
||||||
|
OP_PONG = 0xA
|
||||||
|
|
||||||
|
MAX_MESSAGE_LEN = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
def __init__(self, reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter):
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
self._closed = False
|
||||||
|
self._frag = bytearray()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
asyncio.open_connection(
|
||||||
|
host, 443,
|
||||||
|
ssl=ssl_context,
|
||||||
|
server_hostname=sni,
|
||||||
|
)
|
||||||
|
if secure
|
||||||
|
else asyncio.open_connection(host, 80)
|
||||||
|
),
|
||||||
|
timeout=min(timeout, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
line = await asyncio.wait_for(reader.readline(),
|
||||||
|
timeout=timeout)
|
||||||
|
if line in (b'\r\n', b'\n', b''):
|
||||||
|
break
|
||||||
|
response_lines.append(
|
||||||
|
line.decode('utf-8', errors='replace').strip())
|
||||||
|
|
||||||
|
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()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def send(self, data: bytes):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
frame = self._build_frame(self.OP_BINARY, data, mask=True)
|
||||||
|
self.writer.write(frame)
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def send_batch(self, parts: List[bytes]):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
for part in parts:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def recv(self) -> Optional[bytes]:
|
||||||
|
while not self._closed:
|
||||||
|
opcode, payload, fin = await self._read_frame()
|
||||||
|
|
||||||
|
if opcode == self.OP_CLOSE:
|
||||||
|
self._closed = True
|
||||||
|
code, reason = self._parse_close(payload)
|
||||||
|
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||||
|
code, reason)
|
||||||
|
try:
|
||||||
|
self.writer.write(self._build_frame(
|
||||||
|
self.OP_CLOSE,
|
||||||
|
payload[:2] if payload else b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
if opcode == self.OP_PING:
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_PONG, payload, mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode == self.OP_PONG:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if opcode in (self.OP_CONT, 0x1, self.OP_BINARY):
|
||||||
|
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
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.writer.write(
|
||||||
|
self._build_frame(self.OP_CLOSE, b'', mask=True))
|
||||||
|
await self.writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_WS_CLOSE_REASONS = {
|
||||||
|
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
|
||||||
|
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
|
||||||
|
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
|
||||||
|
1011: 'internal_error',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
|
||||||
|
if not payload or len(payload) < 2:
|
||||||
|
return None, ''
|
||||||
|
try:
|
||||||
|
code = int.from_bytes(payload[:2], 'big')
|
||||||
|
text = payload[2:].decode('utf-8', errors='replace')
|
||||||
|
name = cls._WS_CLOSE_REASONS.get(code)
|
||||||
|
return code, f"{text} ({name})" if name else text
|
||||||
|
except Exception:
|
||||||
|
return None, ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_frame(opcode: int, data: bytes,
|
||||||
|
mask: bool = False) -> bytes:
|
||||||
|
length = len(data)
|
||||||
|
fb = 0x80 | opcode
|
||||||
|
if not mask:
|
||||||
|
if length < 126:
|
||||||
|
return _st_BB.pack(fb, length) + data
|
||||||
|
if length < 65536:
|
||||||
|
return _st_BBH.pack(fb, 126, length) + data
|
||||||
|
return _st_BBQ.pack(fb, 127, length) + data
|
||||||
|
mask_key = os.urandom(4)
|
||||||
|
masked = _xor_mask(data, mask_key)
|
||||||
|
if length < 126:
|
||||||
|
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
|
||||||
|
if length < 65536:
|
||||||
|
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
||||||
|
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
||||||
|
|
||||||
|
async def _read_frame(self) -> Tuple[int, bytes, bool]:
|
||||||
|
hdr = await self.reader.readexactly(2)
|
||||||
|
fin = bool(hdr[0] & 0x80)
|
||||||
|
opcode = hdr[0] & 0x0F
|
||||||
|
length = hdr[1] & 0x7F
|
||||||
|
if length == 126:
|
||||||
|
length = _st_H.unpack(await self.reader.readexactly(2))[0]
|
||||||
|
elif length == 127:
|
||||||
|
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
|
||||||
|
if length > self.MAX_MESSAGE_LEN:
|
||||||
|
raise ConnectionError(f"WS frame too large: {length} bytes")
|
||||||
|
if hdr[1] & 0x80:
|
||||||
|
mask_key = await self.reader.readexactly(4)
|
||||||
|
payload = await self.reader.readexactly(length)
|
||||||
|
return opcode, _xor_mask(payload, mask_key), fin
|
||||||
|
payload = await self.reader.readexactly(length)
|
||||||
|
return opcode, payload, fin
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from .utils import human_bytes
|
||||||
|
|
||||||
|
class _Stats:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections_total = 0
|
||||||
|
self.connections_active = 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_cfproxy = 0
|
||||||
|
self.connections_fronting = 0
|
||||||
|
self.connections_bad = 0
|
||||||
|
self.connections_masked = 0
|
||||||
|
self.ws_errors = 0
|
||||||
|
self.bytes_up = 0
|
||||||
|
self.bytes_down = 0
|
||||||
|
self.pool_hits = 0
|
||||||
|
self.pool_misses = 0
|
||||||
|
self.cf_pool_hits = 0
|
||||||
|
self.cf_pool_misses = 0
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
pool_total = self.pool_hits + self.pool_misses
|
||||||
|
pool_s = (f"{self.pool_hits}/{pool_total}"
|
||||||
|
if pool_total else "n/a")
|
||||||
|
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
|
||||||
|
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
|
||||||
|
if cf_pool_total else "n/a")
|
||||||
|
return (f"total={self.connections_total} "
|
||||||
|
f"active={self.connections_active} "
|
||||||
|
f"ws={self.connections_ws} "
|
||||||
|
f"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"cf={self.connections_cfproxy} "
|
||||||
|
f"front={self.connections_fronting} "
|
||||||
|
f"bad={self.connections_bad} "
|
||||||
|
f"masked={self.connections_masked} "
|
||||||
|
f"err={self.ws_errors} "
|
||||||
|
f"pool={pool_s} "
|
||||||
|
f"cf_pool={cf_pool_s} "
|
||||||
|
f"up={human_bytes(self.bytes_up)} "
|
||||||
|
f"down={human_bytes(self.bytes_down)}")
|
||||||
|
|
||||||
|
|
||||||
|
stats = _Stats()
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import socket as _socket
|
||||||
|
import urllib.request
|
||||||
|
import http.client
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import certifi
|
||||||
|
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
|
||||||
|
ZERO_64 = b'\x00' * 64
|
||||||
|
HANDSHAKE_LEN = 64
|
||||||
|
SKIP_LEN = 8
|
||||||
|
PREKEY_LEN = 32
|
||||||
|
KEY_LEN = 32
|
||||||
|
IV_LEN = 16
|
||||||
|
PROTO_TAG_POS = 56
|
||||||
|
DC_IDX_POS = 60
|
||||||
|
|
||||||
|
PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
|
||||||
|
PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
|
||||||
|
PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
|
||||||
|
|
||||||
|
PROTO_ABRIDGED_INT = 0xEFEFEFEF
|
||||||
|
PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
|
||||||
|
PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
||||||
|
|
||||||
|
RESERVED_FIRST_BYTES = {0xEF}
|
||||||
|
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
||||||
|
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
||||||
|
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
||||||
|
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||||
|
|
||||||
|
_GITHUB_IPS: Dict[str, str] = {
|
||||||
|
"release-assets.githubusercontent.com": "185.199.109.133",
|
||||||
|
"raw.githubusercontent.com": "185.199.109.133",
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_DEFAULT_IPS: Dict[int, str] = {
|
||||||
|
1: '149.154.175.50',
|
||||||
|
2: '149.154.167.51',
|
||||||
|
3: '149.154.175.100',
|
||||||
|
4: '149.154.167.91',
|
||||||
|
5: '149.154.171.5',
|
||||||
|
203: '91.105.192.100'
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_TEST_IPS: Dict[int, str] = {
|
||||||
|
1: '149.154.175.10',
|
||||||
|
2: '149.154.167.40',
|
||||||
|
3: '149.154.175.117',
|
||||||
|
}
|
||||||
|
|
||||||
|
WS_PATH = '/apiws'
|
||||||
|
WS_PATH_TEST = WS_PATH + '_test'
|
||||||
|
|
||||||
|
|
||||||
|
def ws_domains(dc: int, is_media: bool) -> List[str]:
|
||||||
|
if dc == 203:
|
||||||
|
dc = 2
|
||||||
|
if not is_media:
|
||||||
|
return [f'kws{dc}.web.telegram.org']
|
||||||
|
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||||
|
|
||||||
|
|
||||||
|
def human_bytes(n: int) -> str:
|
||||||
|
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||||
|
if abs(n) < 1024:
|
||||||
|
return f"{n:.1f}{unit}"
|
||||||
|
n /= 1024 # type: ignore
|
||||||
|
return f"{n:.1f}TB"
|
||||||
|
|
||||||
|
|
||||||
|
def get_link_host(host: str) -> Optional[str]:
|
||||||
|
if host == '0.0.0.0':
|
||||||
|
try:
|
||||||
|
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
|
||||||
|
_s.connect(('8.8.8.8', 80))
|
||||||
|
link_host = _s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
link_host = '127.0.0.1'
|
||||||
|
return link_host
|
||||||
|
else:
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
class 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):
|
||||||
|
def https_open(self, req: Request):
|
||||||
|
host = req.host.split(":")[0]
|
||||||
|
ip = _GITHUB_IPS.get(host)
|
||||||
|
if not ip:
|
||||||
|
return super().https_open(req)
|
||||||
|
pinned = ip
|
||||||
|
|
||||||
|
class _Conn(http.client.HTTPSConnection):
|
||||||
|
def connect(self):
|
||||||
|
self.sock = _socket.create_connection(
|
||||||
|
(pinned, self.port or 443),
|
||||||
|
self.timeout,
|
||||||
|
self.source_address,
|
||||||
|
)
|
||||||
|
if self._tunnel_host:
|
||||||
|
self._tunnel()
|
||||||
|
self.sock = self._context.wrap_socket(
|
||||||
|
self.sock, server_hostname=self._tunnel_host or self.host
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.do_open(_Conn, req, context=self._context)
|
||||||
|
except Exception:
|
||||||
|
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:
|
||||||
|
return urllib.request.build_opener(
|
||||||
|
_PinnedHTTPSHandler(context=create_ssl_context()))
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.25.0"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tg-ws-proxy"
|
||||||
|
dynamic=["version"]
|
||||||
|
|
||||||
|
description = "Telegram Desktop WebSocket Bridge Proxy"
|
||||||
|
readme = "docs/README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
|
license = { name = "MIT", file = "LICENSE" }
|
||||||
|
|
||||||
|
authors = [
|
||||||
|
{ name = "Flowseal" }
|
||||||
|
]
|
||||||
|
|
||||||
|
keywords = [
|
||||||
|
"telegram",
|
||||||
|
"tdesktop",
|
||||||
|
"proxy",
|
||||||
|
"bypass",
|
||||||
|
"websocket",
|
||||||
|
"mtproto",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Intended Audience :: Customer Service",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Topic :: System :: Networking :: Firewalls",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"pyperclip==1.9.0",
|
||||||
|
"certifi",
|
||||||
|
"httpx[http2]==0.28.1",
|
||||||
|
|
||||||
|
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
|
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
|
"Pillow==10.4.0; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
|
|
||||||
|
"psutil==7.0.0; platform_system != 'Windows' or python_version >= '3.9'",
|
||||||
|
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
|
||||||
|
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
|
||||||
|
|
||||||
|
"customtkinter==5.2.2",
|
||||||
|
"pystray==0.19.5",
|
||||||
|
"pyobjc-framework-Cocoa>=9.0; platform_system == 'Darwin'",
|
||||||
|
"Pillow==12.1.0; platform_system == 'Darwin'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tg-ws-proxy = "proxy.tg_ws_proxy:main"
|
||||||
|
tg-ws-proxy-tray-win = "windows:main"
|
||||||
|
tg-ws-proxy-tray-macos = "macos:main"
|
||||||
|
tg-ws-proxy-tray-linux = "linux:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Source = "https://github.com/Flowseal/tg-ws-proxy"
|
||||||
|
Issues = "https://github.com/Flowseal/tg-ws-proxy/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["proxy", "ui", "utils"]
|
||||||
|
|
||||||
|
[tool.hatch.build.force-include]
|
||||||
|
"windows.py" = "windows.py"
|
||||||
|
"macos.py" = "macos.py"
|
||||||
|
"linux.py" = "linux.py"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "proxy/__init__.py"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "B", "C4"]
|
||||||
|
ignore = ["F403", "F405", "B023"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"macos.py" = ["E402"]
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
cryptography==41.0.7
|
|
||||||
customtkinter==5.2.2
|
|
||||||
Pillow==10.4.0
|
|
||||||
psutil==5.9.8
|
|
||||||
pystray==0.19.5
|
|
||||||
pyperclip==1.9.0
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
cryptography==46.0.5
|
|
||||||
customtkinter==5.2.2
|
|
||||||
Pillow==12.1.1
|
|
||||||
psutil==7.0.0
|
|
||||||
pystray==0.19.5
|
|
||||||
pyperclip==1.9.0
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
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(403, extensions={'http_version': b'HTTP/2'})
|
||||||
|
self._transport(handler)
|
||||||
|
channel = self._channel()
|
||||||
|
await channel.send(b'x' * 40, False)
|
||||||
|
with self.assertRaisesRegex(ConnectionError, '403'):
|
||||||
|
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_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 _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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
"""Wire-level regressions: real TLS sockets, ALPN and HTTP/2 flow control."""
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import ipaddress
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from h2.config import H2Configuration
|
||||||
|
from h2.connection import H2Connection
|
||||||
|
from h2.events import DataReceived, RequestReceived, StreamEnded, StreamReset
|
||||||
|
from h2.settings import SettingCodes
|
||||||
|
|
||||||
|
from proxy.h2_transport import H2Transport, STREAM_RECEIVE_WINDOW, _Connection
|
||||||
|
from proxy.cf_h2 import _HttpChannel, _HttpLane, bridge_h2
|
||||||
|
from proxy.utils import PROTO_TAG_INTERMEDIATE
|
||||||
|
|
||||||
|
|
||||||
|
class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
cls.folder = tempfile.TemporaryDirectory(prefix='tg-h2-tests-')
|
||||||
|
cls.addClassCleanup(cls.folder.cleanup)
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')])
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name)
|
||||||
|
.public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(minutes=1))
|
||||||
|
.not_valid_after(now + datetime.timedelta(hours=1))
|
||||||
|
.add_extension(x509.SubjectAlternativeName([
|
||||||
|
x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False)
|
||||||
|
.sign(key, hashes.SHA256()))
|
||||||
|
cls.cert_path = Path(cls.folder.name) / 'cert.pem'
|
||||||
|
key_path = Path(cls.folder.name) / 'key.pem'
|
||||||
|
cls.cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption()))
|
||||||
|
cls.server_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
cls.server_ssl.load_cert_chain(str(cls.cert_path), str(key_path))
|
||||||
|
cls.server_ssl.set_alpn_protocols(['h2'])
|
||||||
|
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.requests = asyncio.Queue()
|
||||||
|
self.resets = asyncio.Queue()
|
||||||
|
self.connections = []
|
||||||
|
self.handlers = []
|
||||||
|
self.tasks = []
|
||||||
|
self.server_max_streams = 64
|
||||||
|
self.server = await asyncio.start_server(self.serve, '127.0.0.1', 0, ssl=self.server_ssl)
|
||||||
|
port = self.server.sockets[0].getsockname()[1]
|
||||||
|
self.lane = _HttpLane('127.0.0.1:%d' % port, 1)
|
||||||
|
await self.lane.client.aclose()
|
||||||
|
self.transport = H2Transport(ssl.create_default_context(cafile=str(self.cert_path)))
|
||||||
|
self.lane.client = httpx.AsyncClient(transport=self.transport, trust_env=False,
|
||||||
|
timeout=httpx.Timeout(2, read=1))
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
for task in self.tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*self.tasks, return_exceptions=True)
|
||||||
|
await self.lane.close()
|
||||||
|
self.server.close()
|
||||||
|
await self.server.wait_closed()
|
||||||
|
for connection in self.connections:
|
||||||
|
connection.writer.close()
|
||||||
|
await asyncio.wait_for(asyncio.gather(*self.handlers), 3)
|
||||||
|
|
||||||
|
async def serve(self, reader, writer):
|
||||||
|
self.handlers.append(asyncio.current_task())
|
||||||
|
conn = SimpleNamespace(h2=H2Connection(config=H2Configuration(client_side=False)),
|
||||||
|
writer=writer, bodies={}, pending={})
|
||||||
|
self.connections.append(conn)
|
||||||
|
conn.h2.initiate_connection()
|
||||||
|
conn.h2.update_settings({SettingCodes.MAX_CONCURRENT_STREAMS: self.server_max_streams})
|
||||||
|
writer.write(conn.h2.data_to_send())
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await reader.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for event in conn.h2.receive_data(data):
|
||||||
|
if isinstance(event, RequestReceived):
|
||||||
|
conn.bodies[event.stream_id] = bytearray()
|
||||||
|
elif isinstance(event, DataReceived):
|
||||||
|
conn.bodies[event.stream_id].extend(event.data)
|
||||||
|
conn.h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
|
||||||
|
elif isinstance(event, StreamEnded):
|
||||||
|
await self.requests.put((conn, event.stream_id, bytes(conn.bodies[event.stream_id])))
|
||||||
|
elif isinstance(event, StreamReset):
|
||||||
|
conn.pending.pop(event.stream_id, None)
|
||||||
|
await self.resets.put((event.stream_id, event.error_code))
|
||||||
|
self.flush(conn)
|
||||||
|
await writer.drain()
|
||||||
|
except (ConnectionError, ssl.SSLError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except (ConnectionError, ssl.SSLError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def flush(self, conn):
|
||||||
|
for sid, (body, end) in list(conn.pending.items()):
|
||||||
|
while body:
|
||||||
|
count = min(len(body), conn.h2.max_outbound_frame_size,
|
||||||
|
conn.h2.local_flow_control_window(sid))
|
||||||
|
if count <= 0:
|
||||||
|
break
|
||||||
|
chunk, body = body[:count], body[count:]
|
||||||
|
conn.h2.send_data(sid, chunk, end_stream=end and not body)
|
||||||
|
if body:
|
||||||
|
conn.pending[sid] = (body, end)
|
||||||
|
else:
|
||||||
|
del conn.pending[sid]
|
||||||
|
conn.writer.write(conn.h2.data_to_send())
|
||||||
|
|
||||||
|
def respond(self, request, body=b'r' * 40, *, end=True, length=None, status=200):
|
||||||
|
conn, sid, _ = request
|
||||||
|
conn.h2.send_headers(sid, [(b':status', str(status).encode()),
|
||||||
|
(b'content-type', b'application/octet-stream'),
|
||||||
|
(b'content-length', str(len(body) if length is None else length).encode())],
|
||||||
|
end_stream=end and not body)
|
||||||
|
if body:
|
||||||
|
conn.pending[sid] = (body, end)
|
||||||
|
self.flush(conn)
|
||||||
|
|
||||||
|
async def post(self, body=b'x' * 40):
|
||||||
|
task = asyncio.create_task(self.lane._post(body, len(self.tasks) + 1))
|
||||||
|
self.tasks.append(task)
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||||
|
self.assertEqual(request[2], body)
|
||||||
|
return task, request
|
||||||
|
|
||||||
|
async def test_fast_response_does_not_wait_for_unrelated_long_polls(self):
|
||||||
|
slow1, request1 = await self.post()
|
||||||
|
slow2, request2 = await self.post()
|
||||||
|
fast, request3 = await self.post()
|
||||||
|
self.respond(request3)
|
||||||
|
self.assertEqual(await asyncio.wait_for(fast, .5), b'r' * 40)
|
||||||
|
self.assertFalse(slow1.done())
|
||||||
|
self.assertFalse(slow2.done())
|
||||||
|
self.respond(request1)
|
||||||
|
self.respond(request2)
|
||||||
|
await asyncio.gather(slow1, slow2)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_debug_trace_follows_real_stream_without_logging_payload(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'trace-test')
|
||||||
|
body = b'private-payload-' + b'x' * 24
|
||||||
|
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||||
|
await channel.send(body, False)
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
trace = next(iter(channel.request_traces.values()))
|
||||||
|
|
||||||
|
async def phase(expected):
|
||||||
|
while trace.phase != expected:
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
|
||||||
|
await asyncio.wait_for(phase('headers'), .5)
|
||||||
|
self.assertEqual(trace.stream, request[1])
|
||||||
|
self.assertEqual(trace.packet, 1)
|
||||||
|
self.assertEqual(trace.request, 1)
|
||||||
|
self.assertIsNotNone(trace.sent_at)
|
||||||
|
self.assertIsNone(trace.headers_at)
|
||||||
|
self.respond(request, b'r' * 20, end=False, length=40)
|
||||||
|
await asyncio.wait_for(phase('body'), .5)
|
||||||
|
self.assertIsNotNone(trace.headers_at)
|
||||||
|
conn, sid, _ = request
|
||||||
|
conn.h2.send_data(sid, b'r' * 20, end_stream=True)
|
||||||
|
self.flush(conn)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||||
|
await asyncio.gather(*list(channel.pending))
|
||||||
|
self.assertEqual(trace.received, 40)
|
||||||
|
self.assertEqual(trace.phase, 'complete')
|
||||||
|
self.assertFalse(channel.request_traces)
|
||||||
|
self.assertIs(channel.recent_traces[-1], trace)
|
||||||
|
self.lane.log_flow(trace.finished_at)
|
||||||
|
await channel.close()
|
||||||
|
output = '\n'.join(captured.output)
|
||||||
|
self.assertIn('http_down=40', output)
|
||||||
|
self.assertIn('small=1 large=0', output)
|
||||||
|
self.assertNotIn('private-payload', output)
|
||||||
|
self.assertFalse(channel.recent_traces)
|
||||||
|
|
||||||
|
async def test_stale_recovery_slot_replaced_without_resetting_original_request(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'recovery-wire-test')
|
||||||
|
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
|
||||||
|
originals = []
|
||||||
|
for body in bodies[:2]:
|
||||||
|
await channel.send(body, False)
|
||||||
|
originals.append(await asyncio.wait_for(self.requests.get(), 1))
|
||||||
|
|
||||||
|
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .1):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
old_poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
other_poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.assertEqual([old_poll[2], other_poll[2]], bodies[:2])
|
||||||
|
|
||||||
|
# The two duplicate requests remain silent. A new native request
|
||||||
|
# also gets no reply until its own duplicate solicits one.
|
||||||
|
await channel.send(bodies[2], False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
probe = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.assertEqual(probe[2], bodies[2])
|
||||||
|
reset_sid, reset_code = await asyncio.wait_for(self.resets.get(), 1)
|
||||||
|
self.assertEqual((reset_sid, reset_code), (old_poll[1], 8))
|
||||||
|
self.assertIn(original[1], self.transport.connection.streams)
|
||||||
|
for request in originals:
|
||||||
|
self.assertIn(request[1], self.transport.connection.streams)
|
||||||
|
self.assertIn(other_poll[1], self.transport.connection.streams)
|
||||||
|
self.assertEqual(len(channel.replay_pending), 2)
|
||||||
|
self.respond(probe, b'r' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||||
|
# The original stream remains usable, even after recovery.
|
||||||
|
self.respond(original, b'o' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'o' * 40)
|
||||||
|
self.assertEqual(channel.replay_rotations, 1)
|
||||||
|
self.assertEqual(self.lane.failed_until, 0)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_normal_long_poll_survives_six_seconds_and_delivers_late_reply(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'normal-long-poll-test')
|
||||||
|
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
|
||||||
|
for body in bodies[:2]:
|
||||||
|
await channel.send(body, False)
|
||||||
|
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||||
|
await asyncio.wait_for(channel.receive(), 1)
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
# Advance request ages, keeping the real TLS/H2 streams open.
|
||||||
|
# Six seconds is still inside Telegram's default 25s HTTP wait.
|
||||||
|
for task in channel.replay_pending:
|
||||||
|
channel.pending_since[task] = time.monotonic() - 6
|
||||||
|
channel.sent_since[task] = time.monotonic() - 6
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertTrue(self.resets.empty(), 'normal HTTP wait was reset before its delayed result')
|
||||||
|
await channel.send(bodies[2], False)
|
||||||
|
fresh = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.assertEqual(fresh[2], bodies[2])
|
||||||
|
self.respond(fresh, b'n' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
|
||||||
|
self.respond(poll, b'd' * (128 * 1024))
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'd' * (128 * 1024))
|
||||||
|
self.assertEqual(channel.replay_rotations, 0)
|
||||||
|
self.assertEqual(channel.replay_requests, 1)
|
||||||
|
|
||||||
|
async def test_replay_rotation_does_not_interrupt_upload(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'replay-upload-test')
|
||||||
|
body = b'samekey!' + b'p' * 32
|
||||||
|
await channel.send(body, False)
|
||||||
|
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||||
|
await asyncio.wait_for(channel.receive(), 1)
|
||||||
|
upload_started, resume = asyncio.Event(), asyncio.Event()
|
||||||
|
send_body = _Connection.send_body
|
||||||
|
|
||||||
|
async def paused_send(connection, request, stream):
|
||||||
|
upload_started.set()
|
||||||
|
await resume.wait()
|
||||||
|
await send_body(connection, request, stream)
|
||||||
|
|
||||||
|
with patch.object(_Connection, 'send_body', paused_send), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
await asyncio.wait_for(upload_started.wait(), 1)
|
||||||
|
probe = next(iter(channel.replay_pending))
|
||||||
|
channel.pending_since[probe] = time.monotonic() - 40
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertFalse(probe.done())
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
self.assertEqual(channel.replay_rotations, 0)
|
||||||
|
resume.set()
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(request)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||||
|
|
||||||
|
async def test_recovery_deadline_starts_after_original_upload_finishes(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'unfinished-upload-test')
|
||||||
|
body = b'samekey!' + b'q' * 32
|
||||||
|
started, resume = asyncio.Event(), asyncio.Event()
|
||||||
|
send_body = _Connection.send_body
|
||||||
|
|
||||||
|
async def paused_send(connection, request, stream):
|
||||||
|
started.set()
|
||||||
|
await resume.wait()
|
||||||
|
await send_body(connection, request, stream)
|
||||||
|
|
||||||
|
with patch.object(_Connection, 'send_body', paused_send), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||||
|
await channel.send(body, False)
|
||||||
|
await asyncio.wait_for(started.wait(), .5)
|
||||||
|
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||||
|
await asyncio.sleep(.06)
|
||||||
|
self.assertEqual(channel.requests, 1, 'unsent original was duplicated')
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
resume.set()
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(original[2], body)
|
||||||
|
self.assertEqual(probe[2], body)
|
||||||
|
self.respond(probe)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
|
||||||
|
async def test_body_tail_does_not_wait_for_unrelated_long_polls(self):
|
||||||
|
slow1, _ = await self.post()
|
||||||
|
slow2, _ = await self.post()
|
||||||
|
fast, request = await self.post()
|
||||||
|
self.respond(request, b'a' * 20, end=False, length=40)
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertFalse(fast.done())
|
||||||
|
conn, sid, _ = request
|
||||||
|
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
|
||||||
|
self.flush(conn)
|
||||||
|
self.assertEqual(await asyncio.wait_for(fast, .5), b'a' * 20 + b'b' * 20)
|
||||||
|
self.assertFalse(slow1.done())
|
||||||
|
self.assertFalse(slow2.done())
|
||||||
|
|
||||||
|
async def test_receiving_body_is_marked_before_request_coroutine_resumes(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'early-headers-test')
|
||||||
|
resume = asyncio.Event()
|
||||||
|
send_body = _Connection.send_body
|
||||||
|
|
||||||
|
async def paused_send(connection, request, stream):
|
||||||
|
await send_body(connection, request, stream)
|
||||||
|
await resume.wait()
|
||||||
|
|
||||||
|
with patch.object(_Connection, 'send_body', paused_send):
|
||||||
|
await channel.send(b'k' * 40, False)
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(request, b'a' * 20, end=False, length=40)
|
||||||
|
|
||||||
|
async def headers_received():
|
||||||
|
while channel.receiving_http == 0:
|
||||||
|
await asyncio.sleep(.005)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(headers_received(), .5)
|
||||||
|
self.assertEqual(channel.receiving_http, 1)
|
||||||
|
self.assertTrue(channel.queue.empty())
|
||||||
|
finally:
|
||||||
|
resume.set()
|
||||||
|
conn, sid, _ = request
|
||||||
|
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
|
||||||
|
self.flush(conn)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 20 + b'b' * 20)
|
||||||
|
self.assertEqual(channel.receiving_http, 0)
|
||||||
|
|
||||||
|
async def test_native_channel_delivers_ready_reply_while_large_body_is_pending(self):
|
||||||
|
class IdentityCipher:
|
||||||
|
def update(self, data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
class Writer:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = bytearray()
|
||||||
|
self.changed = asyncio.Event()
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.data.extend(data)
|
||||||
|
self.changed.set()
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
reader, writer = asyncio.StreamReader(), Writer()
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'wire-test')
|
||||||
|
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
|
||||||
|
bridge = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
|
||||||
|
self.tasks.append(bridge)
|
||||||
|
reader.feed_data(struct.pack('<I', 40) + b'x' * 40)
|
||||||
|
slow = await asyncio.wait_for(self.requests.get(), 2)
|
||||||
|
self.respond(slow, b'a' * 65536, end=False, length=65576)
|
||||||
|
reader.feed_data(struct.pack('<I', 40) + b'y' * 40)
|
||||||
|
fast = await asyncio.wait_for(self.requests.get(), 2)
|
||||||
|
self.respond(fast, b'f' * 40)
|
||||||
|
await asyncio.wait_for(writer.changed.wait(), .5)
|
||||||
|
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40)
|
||||||
|
writer.changed.clear()
|
||||||
|
conn, sid, _ = slow
|
||||||
|
# Finish after the first 64 KiB have passed through flow control.
|
||||||
|
for _ in range(100):
|
||||||
|
if sid not in conn.pending:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
self.assertNotIn(sid, conn.pending)
|
||||||
|
conn.pending[sid] = (b'b' * 40, True)
|
||||||
|
self.flush(conn)
|
||||||
|
await asyncio.wait_for(writer.changed.wait(), .5)
|
||||||
|
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40
|
||||||
|
+ struct.pack('<I', 65576) + b'a' * 65536 + b'b' * 40)
|
||||||
|
|
||||||
|
async def test_parallel_first_windows_do_not_wait_for_connection_credit(self):
|
||||||
|
first, request1 = await self.post()
|
||||||
|
second, request2 = await self.post()
|
||||||
|
self.respond(request1, b'a' * 65532)
|
||||||
|
self.respond(request2, b'b' * 65532)
|
||||||
|
# No event-loop yield between responses: the server has not received
|
||||||
|
# any WINDOW_UPDATE credit for either body yet.
|
||||||
|
self.assertFalse(request1[0].pending)
|
||||||
|
self.assertEqual(await first, b'a' * 65532)
|
||||||
|
self.assertEqual(await second, b'b' * 65532)
|
||||||
|
|
||||||
|
async def test_file_chunk_fits_first_window_without_extra_network_round_trip(self):
|
||||||
|
# A 128 KiB file part plus its MTProto envelope must fit before any
|
||||||
|
# WINDOW_UPDATE arrives. This matters even when the consumer is fast.
|
||||||
|
body = b'f' * (128 * 1024 + 168)
|
||||||
|
task, request = await self.post()
|
||||||
|
self.respond(request, body)
|
||||||
|
self.assertNotIn(request[1], list(request[0].pending))
|
||||||
|
self.assertEqual(await asyncio.wait_for(task, 1), body)
|
||||||
|
|
||||||
|
async def test_stalled_body_does_not_block_recovery_of_another_native_request(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'stalled-body-test')
|
||||||
|
await channel.send(b'k' * 8 + b'a' * 32, False)
|
||||||
|
slow = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(slow, b'a' * 20, end=False, length=40)
|
||||||
|
fresh_body = b'k' * 8 + b'b' * 32
|
||||||
|
await channel.send(fresh_body, False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .05):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(probe[2], fresh_body)
|
||||||
|
self.respond(probe, b'r' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||||
|
self.assertIn(slow[1], self.transport.connection.streams)
|
||||||
|
self.assertIn(original[1], self.transport.connection.streams)
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
conn, sid, _ = slow
|
||||||
|
conn.h2.send_data(sid, b'a' * 20, end_stream=True)
|
||||||
|
self.flush(conn)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'a' * 40)
|
||||||
|
self.assertEqual(self.lane.failed_until, 0)
|
||||||
|
|
||||||
|
async def test_first_recovery_without_result_does_not_end_recovery(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'second-recovery-test')
|
||||||
|
body = b'k' * 40
|
||||||
|
await channel.send(body, False)
|
||||||
|
self.respond(await asyncio.wait_for(self.requests.get(), 1), b'')
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
first = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.respond(first, b'')
|
||||||
|
second = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(first[2], body)
|
||||||
|
self.assertEqual(second[2], body)
|
||||||
|
self.respond(second, b'r' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||||
|
self.assertEqual(channel.replay_requests, 2)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_completed_http_ack_does_not_add_idle_delay_to_each_chunk(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'chunked-download-poll-test')
|
||||||
|
# An HTTP reply can be only an MTProto acknowledgement. The queued
|
||||||
|
# file chunk needs another HTTP receiver, with no new native upload.
|
||||||
|
# Keep the production 1s idle threshold; shorten only scheduler ticks.
|
||||||
|
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||||
|
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||||
|
for index in range(4):
|
||||||
|
body = b'samekey!' + bytes([index]) * 32
|
||||||
|
await channel.send(body, False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.assertEqual(original[2], body)
|
||||||
|
self.respond(original, b'a' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
|
||||||
|
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(poll[2], body)
|
||||||
|
reply = bytes([index]) * (128 * 1024)
|
||||||
|
self.respond(poll, reply)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply)
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_drained_receiver_wakes_first_poll_without_waiting_for_timer(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'receiver-wakeup-test')
|
||||||
|
body = b'samekey!' + b'q' * 32
|
||||||
|
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
|
||||||
|
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||||
|
await channel.send(body, False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(original, b'a' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
|
||||||
|
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(poll[2], body)
|
||||||
|
self.respond(poll, b'f' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'f' * 40)
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertTrue(self.requests.empty(), 'a wakeup bypassed the retry backoff')
|
||||||
|
self.assertEqual(channel.replay_requests, 1)
|
||||||
|
|
||||||
|
async def test_native_write_completion_wakes_poll_but_backpressure_does_not(self):
|
||||||
|
class IdentityCipher:
|
||||||
|
def update(self, data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
class Writer:
|
||||||
|
def __init__(self):
|
||||||
|
self.started, self.resume = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.started.set()
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
await self.resume.wait()
|
||||||
|
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'native-wakeup-test')
|
||||||
|
reader, writer = asyncio.StreamReader(), Writer()
|
||||||
|
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
|
||||||
|
body = b'samekey!' + b'q' * 32
|
||||||
|
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
|
||||||
|
self.tasks.append(asyncio.create_task(
|
||||||
|
bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE)))
|
||||||
|
reader.feed_data(struct.pack('<I', len(body)) + body)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(original)
|
||||||
|
await asyncio.wait_for(writer.started.wait(), .5)
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
writer.resume.set()
|
||||||
|
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(poll[2], body)
|
||||||
|
self.assertIsNone(channel.delivering_since)
|
||||||
|
self.assertEqual(channel.down, 40)
|
||||||
|
|
||||||
|
async def test_idle_poll_preserves_slot_for_new_overdue_request(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'idle-poll-capacity-test')
|
||||||
|
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)]
|
||||||
|
for body in bodies[:3]:
|
||||||
|
await channel.send(body, False)
|
||||||
|
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||||
|
await asyncio.wait_for(channel.receive(), 1)
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .06), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||||
|
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||||
|
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(poll[2], bodies[2])
|
||||||
|
await asyncio.sleep(.04)
|
||||||
|
self.assertTrue(self.requests.empty(), 'completed history occupied the spare recovery slot')
|
||||||
|
await channel.send(bodies[3], False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
recovery = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(original[2], bodies[3])
|
||||||
|
self.assertEqual(recovery[2], bodies[3])
|
||||||
|
self.respond(recovery, b'n' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
|
||||||
|
# Neither the old receiver nor the new original was cancelled.
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
self.respond(poll, b'p' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'p' * 40)
|
||||||
|
self.respond(original, b'o' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'o' * 40)
|
||||||
|
self.assertEqual(channel.replay_rotations, 0)
|
||||||
|
|
||||||
|
async def test_busy_channel_recovers_old_request_despite_newer_replies(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'busy-recovery-test')
|
||||||
|
body = b'samekey!' + b't' * 32
|
||||||
|
original = None
|
||||||
|
recovered = asyncio.Event()
|
||||||
|
chatter_reply = asyncio.Event()
|
||||||
|
chatter_requests = 0
|
||||||
|
|
||||||
|
async def server():
|
||||||
|
nonlocal original, chatter_requests
|
||||||
|
while True:
|
||||||
|
request = await self.requests.get()
|
||||||
|
if request[2] == body:
|
||||||
|
if original is None:
|
||||||
|
original = request
|
||||||
|
else:
|
||||||
|
self.respond(request, b't' * 40)
|
||||||
|
else:
|
||||||
|
chatter_requests += 1
|
||||||
|
self.respond(request, b'c' * 40)
|
||||||
|
|
||||||
|
async def chatter():
|
||||||
|
index = 0
|
||||||
|
while True:
|
||||||
|
chatter_reply.clear()
|
||||||
|
await channel.send(b'samekey!' + struct.pack('<I', index) + b'c' * 28, False)
|
||||||
|
index += 1
|
||||||
|
await chatter_reply.wait()
|
||||||
|
await asyncio.sleep(.005)
|
||||||
|
|
||||||
|
async def receiver():
|
||||||
|
while True:
|
||||||
|
if await channel.receive() == b't' * 40:
|
||||||
|
recovered.set()
|
||||||
|
else:
|
||||||
|
chatter_reply.set()
|
||||||
|
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .05), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .12), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_HISTORY_PACKETS', 3):
|
||||||
|
await channel.send(body, False)
|
||||||
|
self.tasks.extend(asyncio.create_task(coro) for coro in (
|
||||||
|
server(), chatter(), receiver(), channel._recover()))
|
||||||
|
await asyncio.wait_for(recovered.wait(), .6)
|
||||||
|
self.assertGreater(chatter_requests, 3)
|
||||||
|
self.assertLessEqual(len(channel.replay_history), 3)
|
||||||
|
self.assertIn(original[1], self.transport.connection.streams)
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_receiving_other_body_does_not_mask_overdue_request(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'parallel-body-recovery-test')
|
||||||
|
body = b'samekey!' + b't' * 32
|
||||||
|
await channel.send(body, False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
await channel.send(b'samekey!' + b'b' * 32, False)
|
||||||
|
other = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
self.respond(other, b'b' * 4, end=False, length=400)
|
||||||
|
|
||||||
|
async def body_progress():
|
||||||
|
for _ in range(90):
|
||||||
|
await asyncio.sleep(.01)
|
||||||
|
conn, sid, _ = other
|
||||||
|
conn.h2.send_data(sid, b'b' * 4)
|
||||||
|
self.flush(conn)
|
||||||
|
|
||||||
|
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .03), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .07):
|
||||||
|
self.tasks.extend(asyncio.create_task(coro) for coro in (body_progress(), channel._recover()))
|
||||||
|
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(probe[2], body)
|
||||||
|
self.assertEqual(channel.receiving_http, 1)
|
||||||
|
self.respond(probe, b't' * 40)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b't' * 40)
|
||||||
|
self.assertIn(original[1], self.transport.connection.streams)
|
||||||
|
self.assertIn(other[1], self.transport.connection.streams)
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
|
||||||
|
async def test_silent_recovery_polls_refresh_without_new_native_input(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'refresh-recovery-test')
|
||||||
|
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)]
|
||||||
|
for body in bodies:
|
||||||
|
await channel.send(body, False)
|
||||||
|
await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .2), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
latest = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
older = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
channel.sent_since[channel.replay_history[0].pending] = time.monotonic() - 1
|
||||||
|
refreshed = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(refreshed[2], bodies[0])
|
||||||
|
self.assertEqual(latest[2], bodies[0])
|
||||||
|
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (latest[1], 8))
|
||||||
|
self.assertIn(older[1], self.transport.connection.streams)
|
||||||
|
self.assertEqual(len(channel.replay_pending), 2)
|
||||||
|
self.respond(refreshed)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||||
|
self.assertEqual(channel.replay_rotations, 1)
|
||||||
|
|
||||||
|
async def test_recovery_never_resets_replays_with_partially_received_bodies(self):
|
||||||
|
channel = _HttpChannel(self.lane, 1, 'protected-body-test')
|
||||||
|
bodies = [b'k' * 8 + bytes([index]) * 32 for index in range(3)]
|
||||||
|
for body in bodies[:2]:
|
||||||
|
await channel.send(body, False)
|
||||||
|
await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||||
|
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .04):
|
||||||
|
recovery = asyncio.create_task(channel._recover())
|
||||||
|
self.tasks.append(recovery)
|
||||||
|
probes = [await asyncio.wait_for(self.requests.get(), 1) for _ in range(2)]
|
||||||
|
for probe in probes:
|
||||||
|
self.respond(probe, b'p' * 20, end=False, length=40)
|
||||||
|
await channel.send(bodies[2], False)
|
||||||
|
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||||
|
await asyncio.sleep(.1)
|
||||||
|
self.assertEqual(channel.receiving_http, 2)
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
self.assertTrue(self.resets.empty())
|
||||||
|
self.assertIn(original[1], self.transport.connection.streams)
|
||||||
|
# Finishing a partial response frees a slot without throwing its
|
||||||
|
# MTProto result away, and recovery can then use that slot.
|
||||||
|
conn, sid, _ = probes[0]
|
||||||
|
conn.h2.send_data(sid, b'p' * 20, end_stream=True)
|
||||||
|
self.flush(conn)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'p' * 40)
|
||||||
|
fresh_probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||||
|
self.assertEqual(fresh_probe[2], bodies[2])
|
||||||
|
self.respond(fresh_probe)
|
||||||
|
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||||
|
self.assertEqual(channel.replay_rotations, 0)
|
||||||
|
|
||||||
|
async def test_preflight_and_posts_use_same_tls_connection(self):
|
||||||
|
preflight = asyncio.create_task(self.lane._preflight())
|
||||||
|
self.tasks.append(preflight)
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||||
|
self.respond(request, b'', status=501)
|
||||||
|
await preflight
|
||||||
|
task, request = await self.post()
|
||||||
|
self.respond(request)
|
||||||
|
await task
|
||||||
|
self.assertEqual(self.lane.tcp_connections, 1)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_concurrent_waiters_respect_remote_stream_limit(self):
|
||||||
|
self.server_max_streams = 2
|
||||||
|
pending = [asyncio.create_task(self.lane._post(b'x' * 40, i)) for i in range(12)]
|
||||||
|
self.tasks.extend(pending)
|
||||||
|
for _ in range(6):
|
||||||
|
batch = [await asyncio.wait_for(self.requests.get(), 2) for _ in range(2)]
|
||||||
|
await asyncio.sleep(.01)
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
for request in batch:
|
||||||
|
self.respond(request)
|
||||||
|
self.assertEqual(await asyncio.gather(*pending), [b'r' * 40] * 12)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_cancellation_resets_stream_and_reuses_server_capacity(self):
|
||||||
|
self.server_max_streams = 2
|
||||||
|
healthy, healthy_request = await self.post()
|
||||||
|
for _ in range(5):
|
||||||
|
task, request = await self.post()
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||||
|
self.respond(healthy_request)
|
||||||
|
self.assertEqual(await asyncio.wait_for(healthy, .5), b'r' * 40)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_cancel_while_waiting_for_stream_slot_does_not_leak_capacity(self):
|
||||||
|
self.server_max_streams = 1
|
||||||
|
first, request = await self.post()
|
||||||
|
waiting = asyncio.create_task(self.lane._post(b'w' * 40, 2))
|
||||||
|
self.tasks.append(waiting)
|
||||||
|
await asyncio.sleep(.03)
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
waiting.cancel()
|
||||||
|
await asyncio.gather(waiting, return_exceptions=True)
|
||||||
|
self.respond(request)
|
||||||
|
await first
|
||||||
|
next_task, next_request = await self.post()
|
||||||
|
self.respond(next_request)
|
||||||
|
await next_task
|
||||||
|
self.assertFalse(self.transport.connection.streams)
|
||||||
|
|
||||||
|
async def test_large_upload_and_download_cross_flow_control_windows(self):
|
||||||
|
body = b'x' * (256 * 1024)
|
||||||
|
task, request = await self.post(body)
|
||||||
|
self.respond(request, body)
|
||||||
|
self.assertEqual(await asyncio.wait_for(task, 2), body)
|
||||||
|
self.assertEqual(len(self.connections), 1)
|
||||||
|
|
||||||
|
async def test_stalled_body_consumer_does_not_block_other_streams(self):
|
||||||
|
# Keep a streaming response open without reading it. Its window must
|
||||||
|
# bound buffering while other responses can exceed a connection window.
|
||||||
|
opening = asyncio.create_task(self.lane.client.send(
|
||||||
|
self.lane.client.build_request('POST', 'https://' + self.lane.host + '/api', content=b'x' * 40),
|
||||||
|
stream=True))
|
||||||
|
self.tasks.append(opening)
|
||||||
|
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||||
|
self.respond(request, b's' * (1024 * 1024))
|
||||||
|
response = await opening
|
||||||
|
fast, fast_request = await self.post()
|
||||||
|
self.respond(fast_request, b'f' * (128 * 1024))
|
||||||
|
self.assertEqual(await asyncio.wait_for(fast, .5), b'f' * (128 * 1024))
|
||||||
|
stream = self.transport.connection.streams[request[1]]
|
||||||
|
self.assertLessEqual(sum(len(data) for data, _ in stream.chunks), STREAM_RECEIVE_WINDOW)
|
||||||
|
await response.aclose()
|
||||||
|
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||||
|
|
||||||
|
async def test_read_timeout_resets_only_its_stream(self):
|
||||||
|
self.lane.client.timeout = httpx.Timeout(2, read=.15)
|
||||||
|
stalled, request = await self.post()
|
||||||
|
healthy, healthy_request = await self.post()
|
||||||
|
self.respond(healthy_request)
|
||||||
|
self.assertEqual(await healthy, b'r' * 40)
|
||||||
|
with self.assertRaises(httpx.ReadTimeout):
|
||||||
|
await stalled
|
||||||
|
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||||
|
self.assertIsNone(self.transport.connection.error)
|
||||||
|
|
||||||
|
async def test_peer_reset_does_not_break_other_streams(self):
|
||||||
|
bad, request = await self.post()
|
||||||
|
good, good_request = await self.post()
|
||||||
|
conn, sid, _ = request
|
||||||
|
conn.h2.reset_stream(sid, error_code=8)
|
||||||
|
self.flush(conn)
|
||||||
|
with self.assertRaises(httpx.RemoteProtocolError):
|
||||||
|
await bad
|
||||||
|
self.respond(good_request)
|
||||||
|
self.assertEqual(await good, b'r' * 40)
|
||||||
|
|
||||||
|
async def test_connection_loss_fails_waiters_and_next_request_reconnects(self):
|
||||||
|
tasks = [await self.post() for _ in range(3)]
|
||||||
|
self.connections[0].writer.close()
|
||||||
|
for task, _ in tasks:
|
||||||
|
with self.assertRaises(httpx.HTTPError):
|
||||||
|
await asyncio.wait_for(task, .5)
|
||||||
|
task, request = await self.post()
|
||||||
|
self.respond(request)
|
||||||
|
self.assertEqual(await task, b'r' * 40)
|
||||||
|
self.assertEqual(len(self.connections), 2)
|
||||||
|
|
||||||
|
async def test_goaway_fails_pending_without_replaying_and_reconnects(self):
|
||||||
|
task, request = await self.post()
|
||||||
|
conn, _, _ = request
|
||||||
|
conn.h2.close_connection(error_code=0)
|
||||||
|
self.flush(conn)
|
||||||
|
with self.assertRaisesRegex(httpx.RemoteProtocolError, 'GOAWAY'):
|
||||||
|
await task
|
||||||
|
self.assertTrue(self.requests.empty())
|
||||||
|
next_task, next_request = await self.post()
|
||||||
|
self.respond(next_request)
|
||||||
|
await next_task
|
||||||
|
self.assertEqual(len(self.connections), 2)
|
||||||
|
|
||||||
|
async def test_close_stops_reader_and_wakes_pending_requests(self):
|
||||||
|
task, _ = await self.post()
|
||||||
|
connection = self.transport.connection
|
||||||
|
await self.lane.close()
|
||||||
|
with self.assertRaises(httpx.HTTPError):
|
||||||
|
await asyncio.wait_for(task, .5)
|
||||||
|
self.assertTrue(connection.reader_task.done())
|
||||||
|
self.assertTrue(connection.writer.is_closing())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки.
|
||||||
|
Ядро прокси — пакет `proxy`.
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tkinter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
|
_tk_variable_del_guard_installed = False
|
||||||
|
|
||||||
|
|
||||||
|
def install_tkinter_variable_del_guard() -> None:
|
||||||
|
global _tk_variable_del_guard_installed
|
||||||
|
if _tk_variable_del_guard_installed:
|
||||||
|
return
|
||||||
|
_orig = tkinter.Variable.__del__
|
||||||
|
|
||||||
|
def _safe_variable_del(self: Any, _orig: Any = _orig) -> None:
|
||||||
|
try:
|
||||||
|
_orig(self)
|
||||||
|
except (RuntimeError, tkinter.TclError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment]
|
||||||
|
_tk_variable_del_guard_installed = True
|
||||||
|
|
||||||
|
CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560)
|
||||||
|
CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14)
|
||||||
|
FIRST_RUN_SIZE: Tuple[int, int] = (520, 480)
|
||||||
|
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CtkTheme:
|
||||||
|
tg_blue: tuple = ("#3390ec", "#3390ec")
|
||||||
|
tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
|
||||||
|
|
||||||
|
bg: tuple = ("#ffffff", "#1e1e1e")
|
||||||
|
field_bg: tuple = ("#f0f2f5", "#2b2b2b")
|
||||||
|
field_border: tuple = ("#d6d9dc", "#3a3a3a")
|
||||||
|
|
||||||
|
text_primary: tuple = ("#000000", "#ffffff")
|
||||||
|
text_secondary: tuple = ("#707579", "#aaaaaa")
|
||||||
|
|
||||||
|
ui_font_family: str = "Sans"
|
||||||
|
mono_font_family: str = "Monospace"
|
||||||
|
|
||||||
|
|
||||||
|
def ctk_theme_for_platform() -> CtkTheme:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return CtkTheme(ui_font_family="Segoe UI", mono_font_family="Consolas")
|
||||||
|
return CtkTheme()
|
||||||
|
|
||||||
|
|
||||||
|
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ctk_appearance(ctk: Any, mode: str = "auto") -> None:
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_MODE_MAP.get(mode, "system"))
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
||||||
|
sw = root.winfo_screenwidth()
|
||||||
|
sh = root.winfo_screenheight()
|
||||||
|
root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_ctk_toplevel(
|
||||||
|
ctk: Any,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
theme: CtkTheme,
|
||||||
|
topmost: bool = True,
|
||||||
|
after_create: Optional[Callable[[Any], None]] = None,
|
||||||
|
) -> Any:
|
||||||
|
root = ctk.CTkToplevel()
|
||||||
|
root.title(title)
|
||||||
|
root.resizable(False, False)
|
||||||
|
center_ctk_geometry(root, width, height)
|
||||||
|
root.configure(fg_color=theme.bg)
|
||||||
|
if topmost:
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
root.lift()
|
||||||
|
root.focus_force()
|
||||||
|
if after_create:
|
||||||
|
_after_id = root.after(300, lambda: after_create(root))
|
||||||
|
_orig_destroy = root.destroy
|
||||||
|
|
||||||
|
def _safe_destroy():
|
||||||
|
try:
|
||||||
|
root.after_cancel(_after_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_orig_destroy()
|
||||||
|
|
||||||
|
root.destroy = _safe_destroy
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def main_content_frame(
|
||||||
|
ctk: Any,
|
||||||
|
root: Any,
|
||||||
|
theme: CtkTheme,
|
||||||
|
*,
|
||||||
|
padx: int,
|
||||||
|
pady: int,
|
||||||
|
) -> Any:
|
||||||
|
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
|
||||||
|
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
|
||||||
|
return frame
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CtkTooltip:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
widget: Any,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
delay_ms: int = 450,
|
||||||
|
wraplength: int = 320,
|
||||||
|
) -> None:
|
||||||
|
self.widget = widget
|
||||||
|
self.text = text
|
||||||
|
self.delay_ms = delay_ms
|
||||||
|
self.wraplength = wraplength
|
||||||
|
self._after_id: Optional[str] = None
|
||||||
|
self._tip: Optional[ctk.CTkToplevel] = None
|
||||||
|
widget.bind("<Enter>", self._schedule, add="+")
|
||||||
|
widget.bind("<Leave>", self._hide, add="+")
|
||||||
|
widget.bind("<Button>", self._hide, add="+")
|
||||||
|
widget.bind("<Destroy>", self._on_destroy, add="+")
|
||||||
|
|
||||||
|
def _schedule(self, _event: Any = None) -> None:
|
||||||
|
if self.widget is None:
|
||||||
|
return
|
||||||
|
self._cancel_after()
|
||||||
|
self._after_id = self.widget.after(self.delay_ms, self._show)
|
||||||
|
|
||||||
|
def _cancel_after(self) -> None:
|
||||||
|
if self._after_id is not None:
|
||||||
|
try:
|
||||||
|
self.widget.after_cancel(self._after_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._after_id = None
|
||||||
|
|
||||||
|
def _show(self) -> None:
|
||||||
|
self._after_id = None
|
||||||
|
if self._tip is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if not self.widget.winfo_exists():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
tw = ctk.CTkToplevel(self.widget.winfo_toplevel())
|
||||||
|
tw.wm_overrideredirect(True)
|
||||||
|
try:
|
||||||
|
tw.wm_attributes("-topmost", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tw.configure(fg_color="#2b2b2b")
|
||||||
|
lbl = ctk.CTkLabel(
|
||||||
|
tw,
|
||||||
|
text=self.text,
|
||||||
|
justify="left",
|
||||||
|
wraplength=self.wraplength,
|
||||||
|
fg_color="#2b2b2b",
|
||||||
|
text_color="#f0f0f0",
|
||||||
|
corner_radius=0,
|
||||||
|
font=("Segoe UI", 14) if _is_windows() else None,
|
||||||
|
)
|
||||||
|
lbl.pack(padx=10, pady=8)
|
||||||
|
x = self.widget.winfo_rootx() + 12
|
||||||
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
|
tw.wm_geometry(f"+{x}+{y}")
|
||||||
|
self._tip = tw
|
||||||
|
|
||||||
|
def _hide(self, _event: Any = None) -> None:
|
||||||
|
self._cancel_after()
|
||||||
|
if self._tip is not None:
|
||||||
|
try:
|
||||||
|
self._tip.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._tip = None
|
||||||
|
|
||||||
|
def _on_destroy(self, _event: Any = None) -> None:
|
||||||
|
self._hide()
|
||||||
|
self.widget = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows() -> bool:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
return sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
def attach_ctk_tooltip(
|
||||||
|
widget: Any,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
delay_ms: int = 450,
|
||||||
|
wraplength: int = 320,
|
||||||
|
) -> None:
|
||||||
|
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
|
||||||
|
for w in widgets:
|
||||||
|
attach_ctk_tooltip(w, text, **kwargs)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Tuple
|
||||||
|
|
||||||
|
from proxy import get_link_host
|
||||||
|
from ui.ctk_controls import create_checkbox
|
||||||
|
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.settings_form import (
|
||||||
|
TrayConfigFormWidgets as TrayConfigFormWidgets,
|
||||||
|
install_tray_config_form as install_tray_config_form,
|
||||||
|
validate_config_form as validate_config_form,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tray_settings_scroll_and_footer(
|
||||||
|
ctk: Any,
|
||||||
|
content_parent: Any,
|
||||||
|
theme: CtkTheme,
|
||||||
|
) -> Tuple[Any, Any]:
|
||||||
|
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
|
||||||
|
footer.pack(side="bottom", fill="x")
|
||||||
|
scroll = ctk.CTkScrollableFrame(
|
||||||
|
content_parent,
|
||||||
|
fg_color=theme.bg,
|
||||||
|
corner_radius=0,
|
||||||
|
scrollbar_button_color=theme.field_border,
|
||||||
|
scrollbar_button_hover_color=theme.text_secondary,
|
||||||
|
)
|
||||||
|
scroll.pack(fill="both", expand=True)
|
||||||
|
try:
|
||||||
|
scroll._parent_canvas.configure(yscrollincrement=4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return scroll, footer
|
||||||
|
|
||||||
|
|
||||||
|
def install_tray_config_buttons(
|
||||||
|
ctk: Any,
|
||||||
|
frame: Any,
|
||||||
|
theme: CtkTheme,
|
||||||
|
*,
|
||||||
|
on_save: Callable[[], None],
|
||||||
|
on_cancel: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
ctk.CTkFrame(
|
||||||
|
frame,
|
||||||
|
fg_color=theme.field_border,
|
||||||
|
height=1,
|
||||||
|
corner_radius=0,
|
||||||
|
).pack(fill="x", pady=(4, 10))
|
||||||
|
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
btn_frame.pack(fill="x", pady=(0, 0))
|
||||||
|
save_btn = ctk.CTkButton(
|
||||||
|
btn_frame, text=t("button.save"), height=38,
|
||||||
|
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff",
|
||||||
|
command=on_save)
|
||||||
|
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||||
|
attach_ctk_tooltip(save_btn, t("tip.save"))
|
||||||
|
cancel_btn = ctk.CTkButton(
|
||||||
|
btn_frame, text=t("button.cancel"), height=38,
|
||||||
|
font=(theme.ui_font_family, 14), corner_radius=10,
|
||||||
|
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||||
|
text_color=theme.text_primary, border_width=1,
|
||||||
|
border_color=theme.field_border,
|
||||||
|
command=on_cancel)
|
||||||
|
cancel_btn.pack(side="right", fill="x", expand=True)
|
||||||
|
attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
|
||||||
|
|
||||||
|
|
||||||
|
def populate_first_run_window(
|
||||||
|
ctk: Any,
|
||||||
|
root: Any,
|
||||||
|
theme: CtkTheme,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
secret: str,
|
||||||
|
on_done: Callable[[bool], None],
|
||||||
|
) -> None:
|
||||||
|
link_host = get_link_host(host)
|
||||||
|
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
|
fpx, fpy = FIRST_RUN_FRAME_PAD
|
||||||
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
|
|
||||||
|
title_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
title_frame.pack(anchor="w", pady=(0, 16), fill="x")
|
||||||
|
|
||||||
|
accent_bar = ctk.CTkFrame(title_frame, fg_color=theme.tg_blue,
|
||||||
|
width=4, height=32, corner_radius=2)
|
||||||
|
accent_bar.pack(side="left", padx=(0, 12))
|
||||||
|
|
||||||
|
ctk.CTkLabel(title_frame, text=t("first_run.title"),
|
||||||
|
font=(theme.ui_font_family, 17, "bold"),
|
||||||
|
text_color=theme.text_primary).pack(side="left")
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
(t("first_run.how_to"), True),
|
||||||
|
(t("first_run.auto"), True),
|
||||||
|
(t("first_run.auto_hint"), False),
|
||||||
|
(t("first_run.auto_link", url=tg_url), False),
|
||||||
|
("\n" + t("first_run.manual"), True),
|
||||||
|
(t("first_run.manual_path"), False),
|
||||||
|
(t("first_run.manual_mtproto", host=link_host, port=port), False),
|
||||||
|
(t("first_run.manual_secret", secret=secret), False),
|
||||||
|
]
|
||||||
|
|
||||||
|
textbox = ctk.CTkTextbox(
|
||||||
|
frame,
|
||||||
|
font=(theme.ui_font_family, 13),
|
||||||
|
fg_color=theme.bg,
|
||||||
|
border_width=0,
|
||||||
|
text_color=theme.text_primary,
|
||||||
|
activate_scrollbars=False,
|
||||||
|
wrap="word",
|
||||||
|
height=275,
|
||||||
|
)
|
||||||
|
textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
|
||||||
|
textbox._textbox.configure(spacing1=1, spacing3=1)
|
||||||
|
for text, bold in sections:
|
||||||
|
if text.startswith("\n"):
|
||||||
|
textbox.insert("end", "\n")
|
||||||
|
text = text[1:]
|
||||||
|
if bold:
|
||||||
|
textbox.insert("end", text + "\n", "bold")
|
||||||
|
else:
|
||||||
|
textbox.insert("end", text + "\n")
|
||||||
|
textbox.configure(state="disabled")
|
||||||
|
textbox.pack(anchor="w", fill="x")
|
||||||
|
|
||||||
|
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
|
||||||
|
|
||||||
|
ctk.CTkFrame(frame, fg_color=theme.field_border, height=1,
|
||||||
|
corner_radius=0).pack(fill="x", pady=(0, 12))
|
||||||
|
|
||||||
|
auto_var = ctk.BooleanVar(value=True)
|
||||||
|
create_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
||||||
|
auto_var).pack(anchor="w", pady=(0, 16))
|
||||||
|
|
||||||
|
def on_ok():
|
||||||
|
on_done(auto_var.get())
|
||||||
|
|
||||||
|
ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
|
||||||
|
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff",
|
||||||
|
command=on_ok).pack(pady=(0, 0))
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", on_ok)
|
||||||
@@ -0,0 +1,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())
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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Это предупреждение будет показано только один раз."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Вспомогательные утилиты (проверка релизов и т.п.)."""
|
||||||
|
|
||||||
|
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||||
|
|
||||||
|
__all__ = ["RELEASES_PAGE_URL", "get_status", "run_check"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS).
|
||||||
|
Единственное отличие по платформе — ключ autostart только на Windows.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from ui.i18n import detect_system_language
|
||||||
|
|
||||||
|
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||||
|
"port": 1443,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
|
||||||
|
"verbose": False,
|
||||||
|
"check_updates": True,
|
||||||
|
"log_max_mb": 5,
|
||||||
|
"buf_kb": 256,
|
||||||
|
"pool_size": 4,
|
||||||
|
"cfproxy": True,
|
||||||
|
"h2": True,
|
||||||
|
"cfproxy_user_domain_enabled": False,
|
||||||
|
"cfproxy_user_domain": [],
|
||||||
|
"cfproxy_worker_enabled": False,
|
||||||
|
"cfproxy_worker_domain": [],
|
||||||
|
"force_test_dc": False,
|
||||||
|
"no_secure": False,
|
||||||
|
"appearance": "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_tray_config() -> Dict[str, Any]:
|
||||||
|
cfg = deepcopy(_TRAY_DEFAULTS_COMMON)
|
||||||
|
cfg["secret"] = os.urandom(16).hex()
|
||||||
|
cfg["language"] = detect_system_language().value
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
cfg["autostart"] = False
|
||||||
|
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from typing import Optional, Tuple, Callable
|
||||||
|
|
||||||
|
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
|
||||||
|
_WSA_EACCES = 10013
|
||||||
|
_WSA_EFAULT = 10014
|
||||||
|
_WSA_EADDRINUSE = 10048
|
||||||
|
_WSA_EADDRNOTAVAIL = 10049
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
|
||||||
|
"""Map a listen-socket bind failure to a user-facing message.
|
||||||
|
|
||||||
|
Returns None when the exception is not a recognizable bind failure,
|
||||||
|
so callers can fall back to generic handling.
|
||||||
|
"""
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
|
if not isinstance(exc, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
err = exc.errno
|
||||||
|
winerror = getattr(exc, "winerror", None)
|
||||||
|
|
||||||
|
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
|
||||||
|
return t("diagnostics.port_busy"), None
|
||||||
|
if err == errno.EACCES or winerror == _WSA_EACCES:
|
||||||
|
return t("diagnostics.permission"), None
|
||||||
|
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
|
||||||
|
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
|
||||||
|
return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
|
||||||
|
return None, None
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Shared construction of the rotating log file handler.
|
||||||
|
|
||||||
|
Centralizes the rotation invariant so both the tray and the CLI log paths
|
||||||
|
behave identically and the file can never grow without bound (issue #885).
|
||||||
|
|
||||||
|
A ``RotatingFileHandler`` only rotates when ``backupCount >= 1``: CPython's
|
||||||
|
``doRollover`` skips the entire rotation block when ``backupCount == 0``, so
|
||||||
|
``maxBytes`` is silently ignored and the active file grows forever. We force
|
||||||
|
at least one backup here regardless of caller input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging.handlers
|
||||||
|
|
||||||
|
|
||||||
|
_MIN_BYTES = 32 * 1024
|
||||||
|
_MIN_BACKUPS = 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_handler(
|
||||||
|
path: str,
|
||||||
|
log_max_mb: float = 5,
|
||||||
|
backups: int = 1,
|
||||||
|
) -> logging.handlers.RotatingFileHandler:
|
||||||
|
"""Create a RotatingFileHandler that actually rotates.
|
||||||
|
|
||||||
|
``backups`` is clamped to at least 1 so rotation is always active, and
|
||||||
|
``maxBytes`` keeps a small floor so a misconfigured tiny size can't cause
|
||||||
|
rotation on every line.
|
||||||
|
"""
|
||||||
|
max_bytes = max(_MIN_BYTES, int(log_max_mb * 1024 * 1024))
|
||||||
|
backup_count = max(_MIN_BACKUPS, int(backups))
|
||||||
|
return logging.handlers.RotatingFileHandler(
|
||||||
|
path,
|
||||||
|
maxBytes=max_bytes,
|
||||||
|
backupCount=backup_count,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket as _socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
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 utils.default_config import default_tray_config
|
||||||
|
from utils.diagnostics import diagnose_listen_error
|
||||||
|
from utils.logging_setup import build_log_handler
|
||||||
|
|
||||||
|
log = logging.getLogger("tg-ws-tray")
|
||||||
|
|
||||||
|
APP_NAME = "TgWsProxy"
|
||||||
|
PORTABLE_DIR_NAME = "TgWsProxy_data"
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_app_dir() -> Path:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return Path.home() / "Library" / "Application Support" / APP_NAME
|
||||||
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _exe_dir() -> Optional[Path]:
|
||||||
|
try:
|
||||||
|
base = getattr(sys, "frozen", False) and sys.executable or sys.argv[0]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
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()
|
||||||
|
CONFIG_FILE = APP_DIR / "config.json"
|
||||||
|
LOG_FILE = APP_DIR / "proxy.log"
|
||||||
|
FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
|
||||||
|
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
|
||||||
|
|
||||||
|
IS_FROZEN = bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# single-instance lock
|
||||||
|
|
||||||
|
_lock_file_path: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _same_process(meta: dict, proc: psutil.Process) -> bool:
|
||||||
|
try:
|
||||||
|
lock_ct = float(meta.get("create_time", 0.0))
|
||||||
|
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if IS_FROZEN:
|
||||||
|
return APP_NAME.lower() in proc.name().lower()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock() -> bool:
|
||||||
|
global _lock_file_path
|
||||||
|
ensure_dirs()
|
||||||
|
for f in list(APP_DIR.glob("*.lock")):
|
||||||
|
try:
|
||||||
|
pid = int(f.stem)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
meta: dict = {}
|
||||||
|
try:
|
||||||
|
raw = f.read_text(encoding="utf-8").strip()
|
||||||
|
if raw:
|
||||||
|
meta = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
is_running = False
|
||||||
|
try:
|
||||||
|
is_running = _same_process(meta, psutil.Process(pid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if is_running:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lock_file = APP_DIR / f"{os.getpid()}.lock"
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(os.getpid())
|
||||||
|
lock_file.write_text(
|
||||||
|
json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
lock_file.touch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_lock_file_path = lock_file
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def release_lock() -> None:
|
||||||
|
global _lock_file_path
|
||||||
|
if _lock_file_path:
|
||||||
|
try:
|
||||||
|
_lock_file_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_lock_file_path = None
|
||||||
|
|
||||||
|
|
||||||
|
# config
|
||||||
|
|
||||||
|
def _apply_ui_language(cfg: dict) -> None:
|
||||||
|
from ui.i18n import set_language
|
||||||
|
|
||||||
|
set_language(cfg.get("language", DEFAULT_CONFIG["language"]))
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
ensure_dirs()
|
||||||
|
cfg: Optional[dict] = None
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
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():
|
||||||
|
data.setdefault(k, v)
|
||||||
|
cfg = data
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to load config: %s", repr(exc))
|
||||||
|
if cfg is None:
|
||||||
|
cfg = deepcopy(DEFAULT_CONFIG)
|
||||||
|
_apply_ui_language(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# logging
|
||||||
|
|
||||||
|
_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
|
||||||
|
_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||||
|
fh.addFilter(DomainCensorFilter())
|
||||||
|
root.addHandler(fh)
|
||||||
|
|
||||||
|
if not IS_FROZEN:
|
||||||
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
|
ch.setLevel(level)
|
||||||
|
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
|
||||||
|
ch.addFilter(DomainCensorFilter())
|
||||||
|
root.addHandler(ch)
|
||||||
|
|
||||||
|
|
||||||
|
# icon
|
||||||
|
|
||||||
|
def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
margin = 2
|
||||||
|
draw.ellipse([margin, margin, size - margin, size - margin], fill=color)
|
||||||
|
|
||||||
|
for path in _font_paths():
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(path, size=int(size * 0.55))
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), "T", font=font)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
draw.text(
|
||||||
|
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
|
||||||
|
"T",
|
||||||
|
fill=(255, 255, 255, 255),
|
||||||
|
font=font,
|
||||||
|
)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _font_paths():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return ["arial.ttf"]
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return ["/System/Library/Fonts/Helvetica.ttc"]
|
||||||
|
return [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_icon():
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
icon_path = Path(__file__).parents[1] / "icon.ico"
|
||||||
|
if icon_path.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(str(icon_path))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return make_icon_image(64)
|
||||||
|
|
||||||
|
|
||||||
|
# proxy lifecycle
|
||||||
|
|
||||||
|
_proxy_thread: Optional[threading.Thread] = None
|
||||||
|
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||||
|
global _async_stop
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
stop_ev = asyncio.Event()
|
||||||
|
_async_stop = (loop, stop_ev)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Proxy thread crashed: %s", repr(exc))
|
||||||
|
msg, diagnose_called = diagnose_listen_error(exc)
|
||||||
|
if msg:
|
||||||
|
show_error(msg)
|
||||||
|
if diagnose_called:
|
||||||
|
diagnose_called()
|
||||||
|
finally:
|
||||||
|
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()
|
||||||
|
_async_stop = None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_proxy_config(cfg: dict) -> bool:
|
||||||
|
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||||||
|
try:
|
||||||
|
dc_redirects = parse_dc_ip_list(dc_ip_list)
|
||||||
|
except ValueError as e:
|
||||||
|
log.error("Bad config dc_ip: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
pc = proxy_config
|
||||||
|
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
|
pc.dc_redirects = dc_redirects
|
||||||
|
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||||
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
|
pc.cfproxy_h2_media = cfg.get("h2", DEFAULT_CONFIG["h2"])
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||||
|
global _proxy_thread
|
||||||
|
if _proxy_thread and _proxy_thread.is_alive():
|
||||||
|
log.info("Proxy already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not apply_proxy_config(cfg):
|
||||||
|
from ui.i18n import t
|
||||||
|
on_error(t("error.dc_config"))
|
||||||
|
return
|
||||||
|
|
||||||
|
pc = proxy_config
|
||||||
|
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||||
|
_proxy_thread = threading.Thread(
|
||||||
|
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||||||
|
)
|
||||||
|
_proxy_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_proxy() -> None:
|
||||||
|
global _proxy_thread, _async_stop
|
||||||
|
if _async_stop:
|
||||||
|
loop, stop_ev = _async_stop
|
||||||
|
loop.call_soon_threadsafe(stop_ev.set)
|
||||||
|
if _proxy_thread:
|
||||||
|
_proxy_thread.join(timeout=5)
|
||||||
|
if _proxy_thread.is_alive():
|
||||||
|
log.warning("Proxy thread did not stop within timeout; "
|
||||||
|
"port may still be in use")
|
||||||
|
_proxy_thread = None
|
||||||
|
log.info("Proxy stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||||
|
log.info("Restarting proxy...")
|
||||||
|
stop_proxy()
|
||||||
|
time.sleep(1.0)
|
||||||
|
start_proxy(cfg, on_error)
|
||||||
|
|
||||||
|
|
||||||
|
def tg_proxy_url(cfg: dict) -> str:
|
||||||
|
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||||
|
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||||
|
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||||
|
link_host = get_link_host(host)
|
||||||
|
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_ipv6() -> bool:
|
||||||
|
try:
|
||||||
|
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
||||||
|
ip = addr[4][0]
|
||||||
|
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
|
||||||
|
s.bind(("::1", 0))
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
||||||
|
return
|
||||||
|
IPV6_WARN_MARKER.touch()
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: show_info(t("ipv6.warning"), t("app.name")),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
# update check
|
||||||
|
|
||||||
|
def maybe_notify_update(
|
||||||
|
cfg: dict,
|
||||||
|
is_exiting: Callable[[], bool],
|
||||||
|
ask_open: Callable[[str, str], bool],
|
||||||
|
) -> None:
|
||||||
|
if not cfg.get("check_updates", True):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _work():
|
||||||
|
time.sleep(1.5)
|
||||||
|
if is_exiting():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
run_check(__version__)
|
||||||
|
st = get_status()
|
||||||
|
if not st.get("has_update"):
|
||||||
|
return
|
||||||
|
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
|
ver = st.get("latest") or "?"
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
|
if ask_open(
|
||||||
|
t("update.ask_open", version=ver),
|
||||||
|
t("app.update_title"),
|
||||||
|
):
|
||||||
|
webbrowser.open(url)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Update check failed: %s", repr(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||||
|
|
||||||
|
|
||||||
|
# ctk thread (windows / linux)
|
||||||
|
|
||||||
|
_ctk_root: Any = None
|
||||||
|
_ctk_root_ready = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||||
|
global _ctk_root
|
||||||
|
if ctk is None:
|
||||||
|
return False
|
||||||
|
if _ctk_root_ready.is_set():
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
global _ctk_root
|
||||||
|
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||||||
|
|
||||||
|
install_tkinter_variable_del_guard()
|
||||||
|
apply_ctk_appearance(ctk, mode)
|
||||||
|
_ctk_root = ctk.CTk()
|
||||||
|
_ctk_root.withdraw()
|
||||||
|
_ctk_root_ready.set()
|
||||||
|
_ctk_root.mainloop()
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name="ctk-root").start()
|
||||||
|
_ctk_root_ready.wait(timeout=5.0)
|
||||||
|
return _ctk_root is not None
|
||||||
|
|
||||||
|
|
||||||
|
def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
|
||||||
|
if _ctk_root is None:
|
||||||
|
return
|
||||||
|
done = threading.Event()
|
||||||
|
|
||||||
|
def _invoke():
|
||||||
|
try:
|
||||||
|
build_fn(done)
|
||||||
|
except Exception:
|
||||||
|
log.exception("CTk dialog failed")
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
_ctk_root.after(0, _invoke)
|
||||||
|
done.wait()
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def quit_ctk() -> None:
|
||||||
|
if _ctk_root is not None:
|
||||||
|
try:
|
||||||
|
_ctk_root.after(0, _ctk_root.quit)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# common bootstrap
|
||||||
|
|
||||||
|
def bootstrap(cfg: dict) -> None:
|
||||||
|
save_config(cfg)
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
try:
|
||||||
|
LOG_FILE.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
setup_logging(
|
||||||
|
cfg.get("verbose", False),
|
||||||
|
log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
|
||||||
|
)
|
||||||
|
log.info("TG WS Proxy версия %s starting", __version__)
|
||||||
|
log.info("Config: %s", cfg)
|
||||||
|
log.info("Log file: %s", LOG_FILE)
|
||||||