mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-09-07 10:37:00 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -13,3 +13,8 @@ khgrre.com
|
|||||||
ulihssf.com
|
ulihssf.com
|
||||||
tmhqsdqmfpmk.com
|
tmhqsdqmfpmk.com
|
||||||
xwuwoqbm.com
|
xwuwoqbm.com
|
||||||
|
orgcnunpj.com
|
||||||
|
zhkuldz.com
|
||||||
|
zypoljnslxa.com
|
||||||
|
efabnxaowuzs.com
|
||||||
|
zaftuzsftqdq.com
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
trusted_users:
|
||||||
|
- Flowseal
|
||||||
+79
-40
@@ -12,6 +12,31 @@ on:
|
|||||||
description: "Release version tag (e.g. v1.0.0)"
|
description: "Release version tag (e.g. v1.0.0)"
|
||||||
required: false
|
required: false
|
||||||
default: "v1.0.0"
|
default: "v1.0.0"
|
||||||
|
build_windows_x64:
|
||||||
|
description: 'Build Windows x64?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_windows_arm64:
|
||||||
|
description: 'Build Windows ARM64?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_win7:
|
||||||
|
description: 'Build Windows 7 (x64 + x86)?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_macos:
|
||||||
|
description: 'Build macOS universal?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_linux:
|
||||||
|
description: 'Build Linux (amd64/.deb/.rpm)?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -19,6 +44,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build-windows-x64:
|
build-windows-x64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
if: ${{ github.event.inputs.build_windows_x64 == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -78,6 +104,7 @@ jobs:
|
|||||||
|
|
||||||
build-windows-arm64:
|
build-windows-arm64:
|
||||||
runs-on: windows-11-arm
|
runs-on: windows-11-arm
|
||||||
|
if: ${{ github.event.inputs.build_windows_arm64 == 'true' }}
|
||||||
env:
|
env:
|
||||||
CRYPTOGRAPHY_VERSION: "46.0.5"
|
CRYPTOGRAPHY_VERSION: "46.0.5"
|
||||||
ARM64_WHEELHOUSE: wheelhouse-arm64
|
ARM64_WHEELHOUSE: wheelhouse-arm64
|
||||||
@@ -154,6 +181,7 @@ jobs:
|
|||||||
|
|
||||||
build-win7:
|
build-win7:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
if: ${{ github.event.inputs.build_win7 == 'true' }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
@@ -207,6 +235,9 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
if: ${{ github.event.inputs.build_macos == 'true' }}
|
||||||
|
env:
|
||||||
|
CFFI_VERSION: "2.0.0"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -232,7 +263,7 @@ jobs:
|
|||||||
--python-version 3.12 \
|
--python-version 3.12 \
|
||||||
--implementation cp \
|
--implementation cp \
|
||||||
-d wheelhouse/arm64 \
|
-d wheelhouse/arm64 \
|
||||||
'cffi>=2.0.0' \
|
"cffi==$CFFI_VERSION" \
|
||||||
Pillow==12.1.0 \
|
Pillow==12.1.0 \
|
||||||
psutil==7.0.0
|
psutil==7.0.0
|
||||||
|
|
||||||
@@ -242,7 +273,7 @@ jobs:
|
|||||||
--python-version 3.12 \
|
--python-version 3.12 \
|
||||||
--implementation cp \
|
--implementation cp \
|
||||||
-d wheelhouse/x86_64 \
|
-d wheelhouse/x86_64 \
|
||||||
'cffi>=2.0.0' \
|
"cffi==$CFFI_VERSION" \
|
||||||
Pillow==12.1.0
|
Pillow==12.1.0
|
||||||
|
|
||||||
python3.12 -m pip download \
|
python3.12 -m pip download \
|
||||||
@@ -272,30 +303,16 @@ jobs:
|
|||||||
python3.12 -m pip install .
|
python3.12 -m pip install .
|
||||||
python3.12 -m pip install pyinstaller==6.13.0
|
python3.12 -m pip install pyinstaller==6.13.0
|
||||||
|
|
||||||
- name: Create macOS icon from ICO
|
- name: Validate macOS GUI dependencies
|
||||||
|
run: |
|
||||||
|
python3.12 -m pip check
|
||||||
|
python3.12 -m py_compile macos.py
|
||||||
|
python3.12 -c "import AppKit, customtkinter, macos, pystray, tkinter"
|
||||||
|
|
||||||
|
- name: Create macOS icon
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3.12 - <<'PY'
|
python3.12 macos.py --render-app-icon icon.icns
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
image = Image.open('icon.ico')
|
|
||||||
image = image.resize((1024, 1024), Image.LANCZOS)
|
|
||||||
image.save('icon_1024.png', 'PNG')
|
|
||||||
PY
|
|
||||||
|
|
||||||
mkdir -p icon.iconset
|
|
||||||
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
|
|
||||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
|
|
||||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
|
|
||||||
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
|
|
||||||
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
|
|
||||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
|
|
||||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
|
|
||||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
|
|
||||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
|
|
||||||
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
|
|
||||||
iconutil -c icns icon.iconset -o icon.icns
|
|
||||||
rm -rf icon.iconset icon_1024.png
|
|
||||||
|
|
||||||
- name: Build app with PyInstaller
|
- name: Build app with PyInstaller
|
||||||
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||||
@@ -303,6 +320,12 @@ jobs:
|
|||||||
- name: Validate universal2 app bundle
|
- name: Validate universal2 app bundle
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
|
||||||
|
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
||||||
|
test -n "$ICON_FILE"
|
||||||
|
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
|
||||||
|
test -d "dist/TG WS Proxy.app/Contents/Resources/customtkinter"
|
||||||
|
|
||||||
found=0
|
found=0
|
||||||
while IFS= read -r -d '' file; do
|
while IFS= read -r -d '' file; do
|
||||||
if file "$file" | grep -q "Mach-O"; then
|
if file "$file" | grep -q "Mach-O"; then
|
||||||
@@ -326,22 +349,31 @@ jobs:
|
|||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APP_NAME="TG WS Proxy"
|
packaging/dmg/build_dmg.sh \
|
||||||
DMG_TEMP="dist/dmg_temp"
|
"dist/TG WS Proxy.app" \
|
||||||
|
"TG WS Proxy" \
|
||||||
rm -rf "$DMG_TEMP"
|
|
||||||
mkdir -p "$DMG_TEMP"
|
|
||||||
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
|
|
||||||
ln -s /Applications "$DMG_TEMP/Applications"
|
|
||||||
|
|
||||||
hdiutil create \
|
|
||||||
-volname "$APP_NAME" \
|
|
||||||
-srcfolder "$DMG_TEMP" \
|
|
||||||
-ov \
|
|
||||||
-format UDZO \
|
|
||||||
"dist/TgWsProxy_macos_universal.dmg"
|
"dist/TgWsProxy_macos_universal.dmg"
|
||||||
|
|
||||||
rm -rf "$DMG_TEMP"
|
- name: Validate DMG
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
|
||||||
|
MOUNT_DIR="$(mktemp -d)"
|
||||||
|
DEVICE="$(hdiutil attach \
|
||||||
|
-readonly \
|
||||||
|
-nobrowse \
|
||||||
|
-mountpoint "$MOUNT_DIR" \
|
||||||
|
"$DMG" \
|
||||||
|
| awk '/^\/dev\// { print $1; exit }')"
|
||||||
|
|
||||||
|
test -d "$MOUNT_DIR/TG WS Proxy.app"
|
||||||
|
test -L "$MOUNT_DIR/Applications"
|
||||||
|
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
|
||||||
|
test -f "$MOUNT_DIR/.background/background.tiff"
|
||||||
|
test -f "$MOUNT_DIR/.DS_Store"
|
||||||
|
hdiutil detach "$DEVICE"
|
||||||
|
rmdir "$MOUNT_DIR"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -351,6 +383,7 @@ jobs:
|
|||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.inputs.build_linux == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -517,7 +550,13 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
|
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.inputs.make_release == 'true' }}
|
if: >-
|
||||||
|
${{ github.event.inputs.make_release == 'true'
|
||||||
|
&& github.event.inputs.build_windows_x64 == 'true'
|
||||||
|
&& github.event.inputs.build_windows_arm64 == 'true'
|
||||||
|
&& github.event.inputs.build_win7 == 'true'
|
||||||
|
&& github.event.inputs.build_macos == 'true'
|
||||||
|
&& github.event.inputs.build_linux == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
@@ -531,7 +570,7 @@ jobs:
|
|||||||
tag_name: ${{ github.event.inputs.version }}
|
tag_name: ${{ github.event.inputs.version }}
|
||||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
##
|
---
|
||||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5
|
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi
|
||||||
|
|
||||||
FROM python:3.12-slim AS runtime
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ RUN apt-get update \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
COPY proxy ./proxy
|
COPY proxy ./proxy
|
||||||
|
COPY utils ./utils
|
||||||
COPY docs/README.md LICENSE ./
|
COPY docs/README.md LICENSE ./
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|||||||
@@ -37,12 +37,26 @@ pip install -e .
|
|||||||
|
|
||||||
Подробности: `docs/BuildFromSource.md`.
|
Подробности: `docs/BuildFromSource.md`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Тесты используют только стандартную библиотеку, дополнительные зависимости не нужны:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests -t .
|
||||||
|
```
|
||||||
|
|
||||||
|
Линтер (`ruff` настроен в `pyproject.toml`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|
||||||
Перед открытием PR:
|
Перед открытием PR:
|
||||||
|
|
||||||
1. Убедитесь, что изменение решает конкретную проблему.
|
1. Убедитесь, что изменение решает конкретную проблему.
|
||||||
2. Проверьте, что не сломаны существующие сценарии.
|
2. Проверьте, что не сломаны существующие сценарии: запустите тесты и линтер.
|
||||||
3. Обновите документацию, если меняется поведение или настройка.
|
3. Обновите документацию, если меняется поведение или настройка.
|
||||||
|
|
||||||
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
|
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
|
||||||
@@ -33,6 +33,7 @@ workers.dev
|
|||||||
|
|
||||||
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
|
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
|
||||||
* Пример домена: `random-symbols-1234.username.workers.dev`
|
* Пример домена: `random-symbols-1234.username.workers.dev`
|
||||||
|
* **Можно указывать несколько доменов через запятую (или повторением аргумента `--cfproxy-worker-domain`)**
|
||||||
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
|
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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 |
|
||||||
|
| `--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. |
|
||||||
|
| `--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 pre-allocated connections per DC |
|
||||||
|
| `--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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
tg-ws-proxy -v
|
||||||
|
|
||||||
|
# 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,32 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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,12 @@
|
|||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### 🎉 Support Me
|
||||||
|
>
|
||||||
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
> **Other coins**: https://nowpayments.io/donation/flowseal
|
||||||
|
|
||||||
|
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,145 @@
|
|||||||
|
<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`
|
||||||
|
> **Other coins**: https://nowpayments.io/donation/flowseal
|
||||||
|
|
||||||
|
> [!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).
|
||||||
+19
-12
@@ -1,3 +1,9 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**🇷🇺 Русский • [🇬🇧 English](./EN/README.md)**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<br />
|
<br />
|
||||||
<p>
|
<p>
|
||||||
@@ -9,7 +15,7 @@
|
|||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> ### [🎉 Поддержать меня](./Funding.md)
|
> ### [🎉 Поддержать меня](./RU/Funding.md)
|
||||||
>
|
>
|
||||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
@@ -40,16 +46,17 @@
|
|||||||
## Навигация
|
## Навигация
|
||||||
|
|
||||||
- **🚀 Быстрый старт**
|
- **🚀 Быстрый старт**
|
||||||
- **[Windows](./README.windows.md)**
|
- **[Windows](./RU/README.windows.md)**
|
||||||
- **[macOS](./README.macos.md)**
|
- **[macOS](./RU/README.macos.md)**
|
||||||
- **[Linux](./README.linux.md)**
|
- **[Linux](./RU/README.linux.md)**
|
||||||
- **[Docker](./README.docker.md)**
|
- **[Docker](./RU/README.docker.md)**
|
||||||
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
|
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./RU/CfWorker.md)
|
||||||
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
|
- [Настройка Cloudflare-домена (CF-прокси)](./RU/CfProxy.md)
|
||||||
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
- [Тестовое окружение Telegram (тестовые DC)](./RU/TestDc.md)
|
||||||
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
- [Fake TLS + upstream в Nginx](./RU/FakeTlsNginx.md)
|
||||||
- [Установка из исходников](./BuildFromSource.md)
|
- [Файлы конфигурации Tray-приложения](./RU/TrayConfig.md)
|
||||||
- [Руководство для контрибьюторов](../CONTRIBUTING.md)
|
- [Установка из исходников](./RU/BuildFromSource.md)
|
||||||
|
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
|
||||||
|
|
||||||
## Windows: быстрый вход
|
## Windows: быстрый вход
|
||||||
|
|
||||||
@@ -109,7 +116,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
|
|||||||
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
||||||
> **Если это не помогло, полностью очистите это поле**
|
> **Если это не помогло, полностью очистите это поле**
|
||||||
> Подобная проблема встречается на аккаунтах без Premium
|
> Подобная проблема встречается на аккаунтах без Premium
|
||||||
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
|
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./RU/CfProxy.md)
|
||||||
|
|
||||||
## Автоматическая сборка
|
## Автоматическая сборка
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ tg-ws-proxy-tray-win
|
|||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
|
Требуется сборка Python с поддержкой Tk. Проверить её можно командой `python3 -m tkinter`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e .
|
pip install -e .
|
||||||
tg-ws-proxy-tray-macos
|
tg-ws-proxy-tray-macos
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Cloudflare-прокси
|
||||||
|
|
||||||
|
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
|
||||||
|
|
||||||
|
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
|
||||||
|
|
||||||
|
## Зачем мне настраивать свой домен?
|
||||||
|
|
||||||
|
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
|
||||||
|
|
||||||
|
## Настройка своего домена
|
||||||
|
|
||||||
|
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
|
||||||
|
|
||||||
|
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
|
||||||
|
|
||||||
|
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
|
||||||
|
- Name=`kws1` IPv4=`149.154.175.50`
|
||||||
|
- Name=`kws2` IPv4=`149.154.167.51`
|
||||||
|
- Name=`kws3` IPv4=`149.154.175.100`
|
||||||
|
- Name=`kws4` IPv4=`149.154.167.91`
|
||||||
|
- Name=`kws5` IPv4=`149.154.171.5`
|
||||||
|
- Name=`kws203` IPv4=`91.105.192.100`
|
||||||
|
|
||||||
|
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
|
||||||
|
|
||||||
|
5. В настройках `TgWsProxy` замените домен на свой.
|
||||||
|
|
||||||
|
## Благодарности
|
||||||
|
|
||||||
|
- Идея: https://github.com/Nekogram/WSProxy
|
||||||
|
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
|
||||||
@@ -0,0 +1,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,12 @@
|
|||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> ### 🎉 Поддержать меня
|
||||||
|
>
|
||||||
|
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||||
|
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||||
|
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||||
|
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||||
|
|
||||||
|
Проект полностью бесплатен для всех.
|
||||||
|
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||||
|
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
|
|
||||||
|
Для интерфейса требуются Tk, CustomTkinter и доступ к Cocoa через PyObjC. Они устанавливаются автоматически, кроме Tk, который должен входить в используемую сборку Python.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e .
|
pip install -e .
|
||||||
tg-ws-proxy-tray-macos
|
tg-ws-proxy-tray-macos
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
- **Порт:** `1443` (или переопределенный вами)
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
- **Secret:** из настроек или логов
|
- **Secret:** из настроек или логов
|
||||||
|
|
||||||
|
## Портативный режим
|
||||||
|
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||||
|
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||||
|
|
||||||
## Установка из исходников
|
## Установка из исходников
|
||||||
|
|
||||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Тестовое окружение Telegram (тестовые DC)
|
||||||
|
|
||||||
|
Маршрутизация трафика к тестовым дата-центрам Telegram (test environment).
|
||||||
|
Нужна для разработки/тестирования ботов и клиентов в тестовой среде Telegram.
|
||||||
|
|
||||||
|
## Как включается
|
||||||
|
|
||||||
|
**Автоматически.** Telegram Desktop помечает тестовые DC сдвигом +10000
|
||||||
|
(10001–10003). Прокси распознаёт сдвиг сам — включать ничего не нужно, можно
|
||||||
|
одновременно пользоваться продовым и тестовым аккаунтом в одном клиенте.
|
||||||
|
|
||||||
|
**Принудительно.** Для клиентов, которые сообщают тестовые DC как обычные 1-3
|
||||||
|
(Telethon, TDLib) — распознать их автоматически нельзя. Тогда весь трафик
|
||||||
|
принудительно направляется на тестовые DC (продовые аккаунты через этот прокси
|
||||||
|
работать перестанут). Для принудительной работы используйте флаг `--force-test-dc` в CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tg-ws-proxy --force-test-dc # + ваши --secret / --port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
Работает только для маршрутов **прямой DC → IP** и **Cloudflare Worker** (см. [Настройка Cloudflare Worker'а](./CfWorker.md)).
|
||||||
@@ -23,6 +23,7 @@ Tray-приложение хранит данные в:
|
|||||||
"cfproxy": true,
|
"cfproxy": true,
|
||||||
"cfproxy_user_domain": "",
|
"cfproxy_user_domain": "",
|
||||||
"cfproxy_worker_domain": "",
|
"cfproxy_worker_domain": "",
|
||||||
|
"force_test_dc": false,
|
||||||
"appearance": "auto"
|
"appearance": "auto"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 473 B After Width: | Height: | Size: 22 KiB |
@@ -30,6 +30,7 @@ from ui.ctk_theme import (
|
|||||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||||
)
|
)
|
||||||
|
from ui.i18n import set_language, t
|
||||||
|
|
||||||
_tray_icon: Optional[object] = None
|
_tray_icon: Optional[object] = None
|
||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
@@ -53,16 +54,16 @@ def _msgbox(kind: str, text: str, title: str, **kw):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
|
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||||
_msgbox("showerror", text, title)
|
_msgbox("showerror", text, title or t("app.error_title"))
|
||||||
|
|
||||||
|
|
||||||
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
|
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||||
_msgbox("showinfo", text, title)
|
_msgbox("showinfo", text, title or t("app.name"))
|
||||||
|
|
||||||
|
|
||||||
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
|
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||||
return bool(_msgbox("askyesno", text, title))
|
return bool(_msgbox("askyesno", text, title or t("app.name")))
|
||||||
|
|
||||||
|
|
||||||
def _apply_window_icon(root) -> None:
|
def _apply_window_icon(root) -> None:
|
||||||
@@ -80,12 +81,10 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
|
|||||||
log.info("Copying %s", url)
|
log.info("Copying %s", url)
|
||||||
try:
|
try:
|
||||||
pyperclip.copy(url)
|
pyperclip.copy(url)
|
||||||
_show_info(
|
_show_info(t("dialog.copy_ok", url=url))
|
||||||
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Clipboard copy failed: %s", exc)
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
def _on_copy_link(icon=None, item=None) -> None:
|
def _on_copy_link(icon=None, item=None) -> None:
|
||||||
@@ -95,7 +94,7 @@ def _on_copy_link(icon=None, item=None) -> None:
|
|||||||
pyperclip.copy(url)
|
pyperclip.copy(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Clipboard copy failed: %s", exc)
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
def _on_restart(icon=None, item=None) -> None:
|
def _on_restart(icon=None, item=None) -> None:
|
||||||
@@ -118,7 +117,7 @@ def _on_open_logs(icon=None, item=None) -> None:
|
|||||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_show_info("Файл логов ещё не создан.")
|
_show_info(t("dialog.log_not_found"))
|
||||||
|
|
||||||
|
|
||||||
def _on_exit(icon=None, item=None) -> None:
|
def _on_exit(icon=None, item=None) -> None:
|
||||||
@@ -139,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error(t("dialog.ctk_missing"))
|
||||||
return
|
return
|
||||||
|
|
||||||
cfg = dict(_config)
|
cfg = dict(_config)
|
||||||
@@ -148,41 +147,61 @@ def _edit_config_dialog() -> None:
|
|||||||
theme = ctk_theme_for_platform()
|
theme = ctk_theme_for_platform()
|
||||||
w, h = CONFIG_DIALOG_SIZE
|
w, h = CONFIG_DIALOG_SIZE
|
||||||
root = create_ctk_toplevel(
|
root = create_ctk_toplevel(
|
||||||
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
|
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||||
after_create=_apply_window_icon,
|
after_create=_apply_window_icon,
|
||||||
)
|
)
|
||||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
||||||
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
|
|
||||||
|
def _refresh_tray_menu() -> None:
|
||||||
|
if _tray_icon is not None:
|
||||||
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
|
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
|
||||||
|
|
||||||
|
widgets = install_tray_config_form(
|
||||||
|
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||||
|
show_autostart=False,
|
||||||
|
on_language_change=_refresh_tray_menu,
|
||||||
|
)
|
||||||
|
|
||||||
_original_appearance = ctk.get_appearance_mode()
|
_original_appearance = ctk.get_appearance_mode()
|
||||||
|
|
||||||
|
def _restore_ui_locale() -> None:
|
||||||
|
set_language(_original_language)
|
||||||
|
_refresh_tray_menu()
|
||||||
|
|
||||||
def _finish() -> None:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
def _cancel() -> None:
|
def _cancel() -> None:
|
||||||
ctk.set_appearance_mode(_original_appearance)
|
ctk.set_appearance_mode(_original_appearance)
|
||||||
|
_restore_ui_locale()
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
def on_save() -> None:
|
def on_save() -> None:
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
|
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
|
||||||
if isinstance(merged, str):
|
if isinstance(merged, str):
|
||||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
_ui_only_keys = {"appearance", "check_updates"}
|
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
|
||||||
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
|
|
||||||
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
|
_ui_only_keys = {"appearance", "check_updates", "language"}
|
||||||
|
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
|
||||||
|
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
|
||||||
|
|
||||||
if not config_changed:
|
if not config_changed:
|
||||||
|
_restore_ui_locale()
|
||||||
_finish()
|
_finish()
|
||||||
return
|
return
|
||||||
|
|
||||||
save_config(merged)
|
save_config(merged)
|
||||||
_config.update(merged)
|
_config.update(merged)
|
||||||
|
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
|
||||||
log.info("Config saved: %s", merged)
|
log.info("Config saved: %s", merged)
|
||||||
_tray_icon.menu = _build_menu()
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
@@ -191,8 +210,8 @@ def _edit_config_dialog() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
do_restart = messagebox.askyesno(
|
do_restart = messagebox.askyesno(
|
||||||
"Перезапустить?",
|
t("dialog.restart_title"),
|
||||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
t("dialog.restart_body"),
|
||||||
parent=root,
|
parent=root,
|
||||||
)
|
)
|
||||||
_finish()
|
_finish()
|
||||||
@@ -224,7 +243,7 @@ def _show_first_run() -> None:
|
|||||||
theme = ctk_theme_for_platform()
|
theme = ctk_theme_for_platform()
|
||||||
w, h = FIRST_RUN_SIZE
|
w, h = FIRST_RUN_SIZE
|
||||||
root = create_ctk_toplevel(
|
root = create_ctk_toplevel(
|
||||||
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
|
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||||
after_create=_apply_window_icon,
|
after_create=_apply_window_icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,14 +267,14 @@ def _build_menu():
|
|||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Перезапустить прокси", _on_restart),
|
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||||
pystray.MenuItem("Настройки...", _on_edit_config),
|
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||||
pystray.MenuItem("Открыть логи", _on_open_logs),
|
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Выход", _on_exit),
|
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -283,7 +302,7 @@ def run_tray() -> None:
|
|||||||
_show_first_run()
|
_show_first_run()
|
||||||
check_ipv6_warning(_show_info)
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
|
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
|
||||||
log.info("Tray icon running")
|
log.info("Tray icon running")
|
||||||
_tray_icon.run()
|
_tray_icon.run()
|
||||||
|
|
||||||
@@ -293,7 +312,7 @@ def run_tray() -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if not acquire_lock():
|
if not acquire_lock():
|
||||||
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
|
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
run_tray()
|
run_tray()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Executable
+93
@@ -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"
|
||||||
@@ -11,6 +11,9 @@ 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')
|
||||||
|
|
||||||
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
|
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
|
||||||
gi_hiddenimports = collect_submodules('gi')
|
gi_hiddenimports = collect_submodules('gi')
|
||||||
@@ -26,7 +29,7 @@ a = Analysis(
|
|||||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
|
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'pystray._appindicator',
|
'pystray._appindicator',
|
||||||
'PIL._tkinter_finder',
|
'PIL._tkinter_finder',
|
||||||
|
|||||||
+15
-9
@@ -1,22 +1,31 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
|
import customtkinter
|
||||||
|
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||||
|
certifi_datas = collect_data_files('certifi')
|
||||||
|
|
||||||
|
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'rumps',
|
'tkinter',
|
||||||
|
'customtkinter',
|
||||||
|
'pystray._darwin',
|
||||||
|
'PIL._tkinter_finder',
|
||||||
'objc',
|
'objc',
|
||||||
'Foundation',
|
'Foundation',
|
||||||
'AppKit',
|
'AppKit',
|
||||||
'PyObjCTools',
|
'PyObjCTools',
|
||||||
'PyObjCTools.AppHelper',
|
'PyObjCTools.MachSignals',
|
||||||
'cryptography.hazmat.primitives.ciphers',
|
'cryptography.hazmat.primitives.ciphers',
|
||||||
'cryptography.hazmat.primitives.ciphers.algorithms',
|
'cryptography.hazmat.primitives.ciphers.algorithms',
|
||||||
'cryptography.hazmat.primitives.ciphers.modes',
|
'cryptography.hazmat.primitives.ciphers.modes',
|
||||||
@@ -28,14 +37,13 @@ a = Analysis(
|
|||||||
excludes=[
|
excludes=[
|
||||||
'PIL._avif',
|
'PIL._avif',
|
||||||
'PIL._webp',
|
'PIL._webp',
|
||||||
'PIL._imagingtk',
|
|
||||||
],
|
],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PIL_EXCLUDE_PYDS = {
|
_PIL_EXCLUDE_PYDS = {
|
||||||
'_avif', '_webp', '_imagingtk',
|
'_avif', '_webp',
|
||||||
'FpxImagePlugin', 'MicImagePlugin',
|
'FpxImagePlugin', 'MicImagePlugin',
|
||||||
}
|
}
|
||||||
a.binaries = [
|
a.binaries = [
|
||||||
@@ -91,7 +99,5 @@ app = BUNDLE(
|
|||||||
'LSMinimumSystemVersion': '10.15',
|
'LSMinimumSystemVersion': '10.15',
|
||||||
'LSUIElement': True,
|
'LSUIElement': True,
|
||||||
'NSHighResolutionCapable': True,
|
'NSHighResolutionCapable': True,
|
||||||
'NSAppleEventsUsageDescription':
|
|
||||||
'TG WS Proxy needs to display dialogs.',
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||||
VSVersionInfo(
|
VSVersionInfo(
|
||||||
ffi=FixedFileInfo(
|
ffi=FixedFileInfo(
|
||||||
filevers=(1, 7, 3, 0),
|
filevers=(1, 10, 0, 0),
|
||||||
prodvers=(1, 7, 3, 0),
|
prodvers=(1, 10, 0, 0),
|
||||||
mask=0x3f,
|
mask=0x3f,
|
||||||
flags=0x0,
|
flags=0x0,
|
||||||
OS=0x40004,
|
OS=0x40004,
|
||||||
@@ -21,12 +21,12 @@ VSVersionInfo(
|
|||||||
[
|
[
|
||||||
StringStruct(u'CompanyName', u'Flowseal'),
|
StringStruct(u'CompanyName', u'Flowseal'),
|
||||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||||
StringStruct(u'FileVersion', u'1.7.3.0'),
|
StringStruct(u'FileVersion', u'1.10.0.0'),
|
||||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||||
StringStruct(u'ProductVersion', u'1.7.3.0'),
|
StringStruct(u'ProductVersion', u'1.10.0.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
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=[
|
||||||
'pystray._win32',
|
'pystray._win32',
|
||||||
'PIL._tkinter_finder',
|
'PIL._tkinter_finder',
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
from .utils import get_link_host, build_github_opener
|
from .utils import get_link_host, build_github_opener
|
||||||
|
|
||||||
__version__ = "1.7.3"
|
__version__ = "1.10.0"
|
||||||
|
|
||||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||||
+39
-46
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
import random
|
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@@ -130,10 +129,11 @@ class MsgSplitter:
|
|||||||
|
|
||||||
|
|
||||||
async def do_fallback(reader, writer, relay_init, label,
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
dc: int, is_media: bool, media_tag: str,
|
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
|
||||||
ctx: CryptoCtx, splitter=None):
|
ctx: CryptoCtx, splitter=None):
|
||||||
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
|
||||||
use_cf = proxy_config.fallback_cfproxy
|
fallback_dst = ip_table.get(dc)
|
||||||
|
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
|
||||||
worker_domains = proxy_config.cfproxy_worker_domains
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
methods: List[str] = []
|
methods: List[str] = []
|
||||||
@@ -149,8 +149,8 @@ async def do_fallback(reader, writer, relay_init, label,
|
|||||||
if method == 'cf_worker' and fallback_dst:
|
if method == 'cf_worker' and fallback_dst:
|
||||||
ok = await _cfproxy_worker_fallback(
|
ok = await _cfproxy_worker_fallback(
|
||||||
reader, writer, relay_init, label, ctx,
|
reader, writer, relay_init, label, ctx,
|
||||||
dc=dc, is_media=is_media, fallback_dst=fallback_dst,
|
dc=dc, is_test_dc=is_test_dc, is_media=is_media,
|
||||||
splitter=splitter)
|
fallback_dst=fallback_dst, splitter=splitter)
|
||||||
if ok:
|
if ok:
|
||||||
return True
|
return True
|
||||||
elif method == 'cf':
|
elif method == 'cf':
|
||||||
@@ -173,7 +173,7 @@ async def do_fallback(reader, writer, relay_init, label,
|
|||||||
|
|
||||||
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||||
ctx: CryptoCtx,
|
ctx: CryptoCtx,
|
||||||
dc: int, is_media: bool,
|
dc: int, is_test_dc: bool, is_media: bool,
|
||||||
fallback_dst: str,
|
fallback_dst: str,
|
||||||
splitter=None):
|
splitter=None):
|
||||||
media_tag = ' media' if is_media else ''
|
media_tag = ' media' if is_media else ''
|
||||||
@@ -181,13 +181,12 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
|||||||
if not worker_domains:
|
if not worker_domains:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
random.shuffle(worker_domains)
|
pooled = None if is_test_dc else await cf_worker_pool.get(
|
||||||
|
dc, fallback_dst, worker_domains)
|
||||||
for worker_domain in worker_domains:
|
if pooled:
|
||||||
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
|
ws, worker_domain = pooled
|
||||||
if ws:
|
log.info("[%s] DC%d%s -> CF worker pool hit via %s for %s",
|
||||||
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
label, dc, media_tag, fallback_dst)
|
|
||||||
else:
|
else:
|
||||||
query = urlencode({
|
query = urlencode({
|
||||||
'dst': fallback_dst,
|
'dst': fallback_dst,
|
||||||
@@ -195,24 +194,30 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
|||||||
})
|
})
|
||||||
path = f'/apiws?{query}'
|
path = f'/apiws?{query}'
|
||||||
|
|
||||||
|
ws = None
|
||||||
|
for worker_domain in cf_worker_pool.available_domains(worker_domains):
|
||||||
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||||
label, dc, media_tag, worker_domain, fallback_dst)
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
timeout=10.0, path=path)
|
timeout=10.0, path=path)
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
cf_worker_pool.report_failure(worker_domain, exc)
|
||||||
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||||
label, dc, media_tag, worker_domain, repr(exc))
|
label, dc, media_tag, worker_domain, repr(exc))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
|
||||||
stats.connections_cfproxy += 1
|
stats.connections_cfproxy += 1
|
||||||
await ws.send(relay_init)
|
await ws.send(relay_init)
|
||||||
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
dc=dc, is_media=is_media,
|
dc=dc, is_media=is_media,
|
||||||
splitter=splitter)
|
splitter=None)
|
||||||
return True
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
@@ -266,26 +271,6 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: Crypt
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _ws_keepalive(ws, interval: float):
|
|
||||||
"""Send periodic WS PING frames to keep the upstream flow warm.
|
|
||||||
|
|
||||||
A non-positive interval disables keepalive. The loop exits on send
|
|
||||||
failure so a dead upstream is detected promptly instead of lingering
|
|
||||||
until the next client packet (see issue #646).
|
|
||||||
"""
|
|
||||||
if interval <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
interval = max(1.0, interval) # reasonable minimum
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
await ws.send_ping()
|
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
ctx: CryptoCtx,
|
ctx: CryptoCtx,
|
||||||
dc=None, is_media=False,
|
dc=None, is_media=False,
|
||||||
@@ -302,9 +287,10 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
up_packets = 0
|
up_packets = 0
|
||||||
down_packets = 0
|
down_packets = 0
|
||||||
start_time = asyncio.get_running_loop().time()
|
start_time = asyncio.get_running_loop().time()
|
||||||
|
close_reason = 'normal'
|
||||||
|
|
||||||
async def tcp_to_ws():
|
async def tcp_to_ws():
|
||||||
nonlocal up_bytes, up_packets
|
nonlocal up_bytes, up_packets, close_reason
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = await reader.read(65536)
|
chunk = await reader.read(65536)
|
||||||
@@ -330,17 +316,22 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
await ws.send(parts[0])
|
await ws.send(parts[0])
|
||||||
else:
|
else:
|
||||||
await ws.send(chunk)
|
await ws.send(chunk)
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
close_reason = f"client: {type(e).__name__}: {e}"
|
||||||
log.debug("[%s] tcp->ws ended: %s", label, e)
|
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||||
|
|
||||||
async def ws_to_tcp():
|
async def ws_to_tcp():
|
||||||
nonlocal down_bytes, down_packets
|
nonlocal down_bytes, down_packets, close_reason
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await ws.recv()
|
data = await ws.recv()
|
||||||
if data is None:
|
if data is None:
|
||||||
|
if close_reason == 'normal':
|
||||||
|
close_reason = 'upstream: ws_close'
|
||||||
break
|
break
|
||||||
n = len(data)
|
n = len(data)
|
||||||
stats.bytes_down += n
|
stats.bytes_down += n
|
||||||
@@ -350,30 +341,32 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
data = ctx.clt_enc.update(plain)
|
data = ctx.clt_enc.update(plain)
|
||||||
writer.write(data)
|
writer.write(data)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
close_reason = f"upstream: {type(e).__name__}"
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
close_reason = 'upstream: tcp_reset'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
close_reason = f"upstream: {type(e).__name__}: {e}"
|
||||||
log.debug("[%s] ws->tcp ended: %s", label, e)
|
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||||
|
|
||||||
tasks = [asyncio.create_task(tcp_to_ws()),
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
asyncio.create_task(ws_to_tcp())]
|
asyncio.create_task(ws_to_tcp())]
|
||||||
keepalive = asyncio.create_task(
|
|
||||||
_ws_keepalive(ws, proxy_config.ws_keepalive_interval))
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
finally:
|
finally:
|
||||||
keepalive.cancel()
|
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
t.cancel()
|
t.cancel()
|
||||||
for t in (*tasks, keepalive):
|
for t in tasks:
|
||||||
try:
|
try:
|
||||||
await t
|
await t
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
elapsed = asyncio.get_running_loop().time() - start_time
|
elapsed = asyncio.get_running_loop().time() - start_time
|
||||||
log.info("[%s] %s WS session closed: "
|
log.info("[%s] %s WS session closed (%s): "
|
||||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||||
label, dc_tag,
|
label, dc_tag, close_reason,
|
||||||
human_bytes(up_bytes), up_packets,
|
human_bytes(up_bytes), up_packets,
|
||||||
human_bytes(down_bytes), down_packets,
|
human_bytes(down_bytes), down_packets,
|
||||||
elapsed)
|
elapsed)
|
||||||
|
|||||||
+16
-5
@@ -34,7 +34,12 @@ _CFPROXY_ENC: List[str] = [
|
|||||||
'khgrre.com',
|
'khgrre.com',
|
||||||
'ulihssf.com',
|
'ulihssf.com',
|
||||||
'tmhqsdqmfpmk.com',
|
'tmhqsdqmfpmk.com',
|
||||||
'xwuwoqbm.com'
|
'xwuwoqbm.com',
|
||||||
|
'orgcnunpj.com',
|
||||||
|
'zhkuldz.com',
|
||||||
|
'zypoljnslxa.com',
|
||||||
|
'efabnxaowuzs.com',
|
||||||
|
'zaftuzsftqdq.com'
|
||||||
]
|
]
|
||||||
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
@@ -67,7 +72,7 @@ class ProxyConfig:
|
|||||||
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||||
fake_tls_domain: str = ''
|
fake_tls_domain: str = ''
|
||||||
proxy_protocol: bool = False
|
proxy_protocol: bool = False
|
||||||
ws_keepalive_interval: float = 30.0
|
force_test_dc: bool = False
|
||||||
|
|
||||||
|
|
||||||
proxy_config = ProxyConfig()
|
proxy_config = ProxyConfig()
|
||||||
@@ -196,13 +201,19 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
|||||||
dc_redirects: Dict[int, str] = {}
|
dc_redirects: Dict[int, str] = {}
|
||||||
for entry in dc_ip_list:
|
for entry in dc_ip_list:
|
||||||
if ':' not in entry:
|
if ':' not in entry:
|
||||||
raise ValueError(
|
err = ValueError(
|
||||||
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
|
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
|
||||||
|
err.entry = entry
|
||||||
|
err.kind = "format"
|
||||||
|
raise err
|
||||||
dc_s, ip_s = entry.split(':', 1)
|
dc_s, ip_s = entry.split(':', 1)
|
||||||
try:
|
try:
|
||||||
dc_n = int(dc_s)
|
dc_n = int(dc_s)
|
||||||
_socket.inet_aton(ip_s)
|
_socket.inet_pton(_socket.AF_INET, ip_s)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
raise ValueError(f"Invalid --dc-ip {entry!r}")
|
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
||||||
|
err.entry = entry
|
||||||
|
err.kind = "invalid"
|
||||||
|
raise err from None
|
||||||
dc_redirects[dc_n] = ip_s
|
dc_redirects[dc_n] = ip_s
|
||||||
return dc_redirects
|
return dc_redirects
|
||||||
|
|||||||
+187
-48
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -15,13 +16,21 @@ log = logging.getLogger('tg-mtproto-proxy')
|
|||||||
|
|
||||||
class _WsPool:
|
class _WsPool:
|
||||||
WS_POOL_MAX_AGE = 120.0
|
WS_POOL_MAX_AGE = 120.0
|
||||||
|
WS_POOL_CHECK_INTERVAL = 5.0
|
||||||
|
REFILL_BACKOFF_INITIAL = 60.0
|
||||||
|
REFILL_BACKOFF_MAX = 3600.0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[Tuple[int, bool], deque] = {}
|
self._idle: Dict[Tuple[int, bool], deque] = {}
|
||||||
self._refilling: Set[Tuple[int, bool]] = set()
|
self._refilling: Set[Tuple[int, bool]] = set()
|
||||||
|
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
|
||||||
|
self._refill_failures: Dict[Tuple[int, bool], int] = {}
|
||||||
|
self._refill_after: Dict[Tuple[int, bool], float] = {}
|
||||||
|
self.try_fronting_first = False
|
||||||
|
|
||||||
async def get(self, dc: int, is_media: bool,
|
async def get(self, dc: int, is_media: bool,
|
||||||
target_ip: str, domains: List[str]
|
target_ip: str, domains: List[str],
|
||||||
|
*, allow_refill: bool = True
|
||||||
) -> Optional[RawWebSocket]:
|
) -> Optional[RawWebSocket]:
|
||||||
key = (dc, is_media)
|
key = (dc, is_media)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -40,19 +49,28 @@ class _WsPool:
|
|||||||
stats.pool_hits += 1
|
stats.pool_hits += 1
|
||||||
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
||||||
dc, 'm' if is_media else '', age, len(bucket))
|
dc, 'm' if is_media else '', age, len(bucket))
|
||||||
|
self.report_success(dc, is_media)
|
||||||
|
if allow_refill:
|
||||||
self._schedule_refill(key, target_ip, domains)
|
self._schedule_refill(key, target_ip, domains)
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
stats.pool_misses += 1
|
stats.pool_misses += 1
|
||||||
|
if allow_refill:
|
||||||
self._schedule_refill(key, target_ip, domains)
|
self._schedule_refill(key, target_ip, domains)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _schedule_refill(self, key, target_ip, domains):
|
def _schedule_refill(self, key, target_ip, domains):
|
||||||
if key in self._refilling:
|
if (key in self._refilling
|
||||||
|
or time.monotonic() < self._refill_after.get(key, 0)):
|
||||||
return
|
return
|
||||||
self._refilling.add(key)
|
self._refilling.add(key)
|
||||||
asyncio.create_task(self._refill(key, target_ip, domains))
|
asyncio.create_task(self._refill(key, target_ip, domains))
|
||||||
|
|
||||||
|
def report_success(self, dc: int, is_media: bool) -> None:
|
||||||
|
key = (dc, is_media)
|
||||||
|
self._refill_failures.pop(key, None)
|
||||||
|
self._refill_after.pop(key, None)
|
||||||
|
|
||||||
async def _refill(self, key, target_ip, domains):
|
async def _refill(self, key, target_ip, domains):
|
||||||
dc, is_media = key
|
dc, is_media = key
|
||||||
try:
|
try:
|
||||||
@@ -60,6 +78,7 @@ class _WsPool:
|
|||||||
needed = proxy_config.pool_size - len(bucket)
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
if needed <= 0:
|
if needed <= 0:
|
||||||
return
|
return
|
||||||
|
connected = 0
|
||||||
tasks = [asyncio.create_task(
|
tasks = [asyncio.create_task(
|
||||||
self._connect_one(target_ip, domains))
|
self._connect_one(target_ip, domains))
|
||||||
for _ in range(needed)]
|
for _ in range(needed)]
|
||||||
@@ -68,19 +87,89 @@ class _WsPool:
|
|||||||
ws = await t
|
ws = await t
|
||||||
if ws:
|
if ws:
|
||||||
bucket.append((ws, time.monotonic()))
|
bucket.append((ws, time.monotonic()))
|
||||||
|
connected += 1
|
||||||
|
self._schedule_rotation(key, target_ip, domains)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if connected:
|
||||||
|
self.report_success(dc, is_media)
|
||||||
|
else:
|
||||||
|
failures = self._refill_failures.get(key, 0) + 1
|
||||||
|
self._refill_failures[key] = failures
|
||||||
|
delay = min(
|
||||||
|
self.REFILL_BACKOFF_INITIAL
|
||||||
|
* (2 ** min(failures - 1, 6)),
|
||||||
|
self.REFILL_BACKOFF_MAX,
|
||||||
|
)
|
||||||
|
self._refill_after[key] = time.monotonic() + delay
|
||||||
|
log.info(
|
||||||
|
"WS pool refill failed for DC%d%s, retry in %.0fs",
|
||||||
|
dc, 'm' if is_media else '', delay)
|
||||||
log.debug("WS pool refilled DC%d%s: %d ready",
|
log.debug("WS pool refilled DC%d%s: %d ready",
|
||||||
dc, 'm' if is_media else '', len(bucket))
|
dc, 'm' if is_media else '', len(bucket))
|
||||||
finally:
|
finally:
|
||||||
self._refilling.discard(key)
|
self._refilling.discard(key)
|
||||||
|
|
||||||
@staticmethod
|
def _schedule_rotation(self, key, target_ip, domains):
|
||||||
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
|
if key in self._rotating:
|
||||||
for domain in domains:
|
return
|
||||||
|
self._rotating[key] = asyncio.create_task(
|
||||||
|
self._rotate(key, target_ip, domains))
|
||||||
|
|
||||||
|
async def _rotate(self, key, target_ip, domains):
|
||||||
|
dc, is_media = key
|
||||||
try:
|
try:
|
||||||
return await RawWebSocket.connect(
|
while True:
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if not bucket:
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = min(
|
||||||
|
created + self.WS_POOL_MAX_AGE
|
||||||
|
for _, created in bucket)
|
||||||
|
await asyncio.sleep(min(
|
||||||
|
self.WS_POOL_CHECK_INTERVAL,
|
||||||
|
max(0, expires_at - time.monotonic())))
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
expired = []
|
||||||
|
ready = deque()
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
if (now - created >= self.WS_POOL_MAX_AGE
|
||||||
|
or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
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: %d stale, %d ready",
|
||||||
|
dc, 'm' if is_media else '', len(expired), len(bucket))
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
finally:
|
||||||
|
if self._rotating.get(key) is asyncio.current_task():
|
||||||
|
self._rotating.pop(key, None)
|
||||||
|
|
||||||
|
async def _connect_one(self, target_ip, domains) -> Optional[RawWebSocket]:
|
||||||
|
for domain in domains:
|
||||||
|
if self.try_fronting_first:
|
||||||
|
ws = await self._connect_fronted(target_ip, domain)
|
||||||
|
if ws:
|
||||||
|
return ws
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(
|
||||||
target_ip, domain, timeout=8)
|
target_ip, domain, timeout=8)
|
||||||
|
self.try_fronting_first = False
|
||||||
|
return ws
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if self.try_fronting_first:
|
||||||
|
return None
|
||||||
|
return await self._connect_fronted(target_ip, domain)
|
||||||
except WsHandshakeError as exc:
|
except WsHandshakeError as exc:
|
||||||
if exc.is_redirect:
|
if exc.is_redirect:
|
||||||
continue
|
continue
|
||||||
@@ -89,8 +178,18 @@ class _WsPool:
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
async def _connect_fronted(self, target_ip, domain) -> Optional[RawWebSocket]:
|
||||||
async def _quiet_close(ws):
|
try:
|
||||||
|
ws = await RawWebSocket.connect(
|
||||||
|
target_ip, domain, timeout=7, sni="sprinthost.ru")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stats.connections_fronting += 1
|
||||||
|
self.try_fronting_first = True
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def _quiet_close(self, ws):
|
||||||
try:
|
try:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -106,85 +205,124 @@ class _WsPool:
|
|||||||
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for task in self._rotating.values():
|
||||||
|
if not task.done() and task.get_loop() is loop:
|
||||||
|
task.cancel()
|
||||||
self._idle.clear()
|
self._idle.clear()
|
||||||
self._refilling.clear()
|
self._refilling.clear()
|
||||||
|
self._rotating.clear()
|
||||||
|
self._refill_failures.clear()
|
||||||
|
self._refill_after.clear()
|
||||||
|
self.try_fronting_first = False
|
||||||
|
|
||||||
|
|
||||||
class _CfWorkerPool:
|
class _CfWorkerPool:
|
||||||
WS_POOL_MAX_AGE = 120.0
|
WS_POOL_MAX_AGE = 100.0
|
||||||
|
PER_DC_LIMIT = 1
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[Tuple[int, str], deque] = {}
|
self._idle: Dict[int, deque] = {}
|
||||||
self._refilling: Set[Tuple[int, str]] = set()
|
self._refilling: Set[int] = set()
|
||||||
|
self._exhausted_until: Dict[str, float] = {}
|
||||||
|
|
||||||
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
|
async def get(self, dc: int, fallback_dst: str,
|
||||||
|
worker_domains: List[str]
|
||||||
|
) -> Optional[Tuple[RawWebSocket, str]]:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
key = (dc, worker_domain)
|
|
||||||
|
|
||||||
bucket = self._idle.get(key)
|
bucket = self._idle.get(dc)
|
||||||
if bucket is None:
|
if bucket is None:
|
||||||
bucket = deque()
|
bucket = deque()
|
||||||
self._idle[key] = bucket
|
self._idle[dc] = bucket
|
||||||
while bucket:
|
while bucket:
|
||||||
ws, created = bucket.popleft()
|
ws, created, worker_domain = bucket.popleft()
|
||||||
age = now - created
|
age = now - created
|
||||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
or ws.writer.transport.is_closing()):
|
or ws.writer.transport.is_closing()):
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
continue
|
continue
|
||||||
stats.cf_pool_hits += 1
|
stats.cf_pool_hits += 1
|
||||||
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
|
log.debug(
|
||||||
dc, age, len(bucket))
|
"CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
|
||||||
self._schedule_refill(key, fallback_dst)
|
dc, worker_domain, age, len(bucket))
|
||||||
return ws
|
self._schedule_refill(dc, fallback_dst, worker_domains)
|
||||||
|
return ws, worker_domain
|
||||||
|
|
||||||
stats.cf_pool_misses += 1
|
stats.cf_pool_misses += 1
|
||||||
self._schedule_refill(key, fallback_dst)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _schedule_refill(self, key, fallback_dst):
|
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
||||||
if key in self._refilling:
|
if dc in self._refilling:
|
||||||
return
|
return
|
||||||
self._refilling.add(key)
|
self._refilling.add(dc)
|
||||||
asyncio.create_task(self._refill(key, fallback_dst))
|
asyncio.create_task(self._refill(
|
||||||
|
dc, fallback_dst, list(worker_domains)))
|
||||||
|
|
||||||
async def _refill(self, key, fallback_dst):
|
async def _refill(self, dc, fallback_dst, worker_domains):
|
||||||
dc, worker_domain = key
|
|
||||||
try:
|
try:
|
||||||
bucket = self._idle.setdefault(key, deque())
|
bucket = self._idle.setdefault(dc, deque())
|
||||||
needed = proxy_config.pool_size - len(bucket)
|
target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
|
||||||
|
needed = target_size - len(bucket)
|
||||||
if needed <= 0:
|
if needed <= 0:
|
||||||
return
|
return
|
||||||
tasks = [asyncio.create_task(
|
|
||||||
self._connect_one(worker_domain, fallback_dst, dc))
|
for _ in range(needed):
|
||||||
for _ in range(needed)]
|
connected = await self._connect_one(
|
||||||
for t in tasks:
|
worker_domains, fallback_dst, dc)
|
||||||
try:
|
if connected is None:
|
||||||
ws = await t
|
break
|
||||||
if ws:
|
ws, worker_domain = connected
|
||||||
bucket.append((ws, time.monotonic()))
|
bucket.append((ws, time.monotonic(), worker_domain))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.debug("CF worker pool refilled DC%d: %d ready",
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
dc, len(bucket))
|
dc, len(bucket))
|
||||||
finally:
|
finally:
|
||||||
self._refilling.discard(key)
|
self._refilling.discard(dc)
|
||||||
|
|
||||||
@staticmethod
|
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
||||||
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
|
|
||||||
query = urlencode({
|
query = urlencode({
|
||||||
'dst': fallback_dst,
|
'dst': fallback_dst,
|
||||||
'dc': str(dc),
|
'dc': str(dc),
|
||||||
})
|
})
|
||||||
path = f'/apiws?{query}'
|
path = f'/apiws?{query}'
|
||||||
|
for worker_domain in self.available_domains(worker_domains):
|
||||||
try:
|
try:
|
||||||
return await RawWebSocket.connect(
|
ws = await RawWebSocket.connect(
|
||||||
worker_domain, worker_domain, timeout=8, path=path)
|
worker_domain, worker_domain, timeout=8, path=path)
|
||||||
except Exception:
|
return ws, worker_domain
|
||||||
|
except Exception as exc:
|
||||||
|
self.report_failure(worker_domain, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
def available_domains(self, worker_domains: List[str]) -> List[str]:
|
||||||
async def _quiet_close(ws):
|
now = time.time()
|
||||||
|
domains = []
|
||||||
|
for domain in worker_domains:
|
||||||
|
if domain in domains:
|
||||||
|
continue
|
||||||
|
exhausted_until = self._exhausted_until.get(domain, 0)
|
||||||
|
if exhausted_until > now:
|
||||||
|
continue
|
||||||
|
if exhausted_until:
|
||||||
|
self._exhausted_until.pop(domain, None)
|
||||||
|
domains.append(domain)
|
||||||
|
random.shuffle(domains)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def report_failure(self, worker_domain: str, exc: Exception) -> None:
|
||||||
|
return # TODO: check status code after daily limit reached
|
||||||
|
if not isinstance(exc, WsHandshakeError) or exc.status_code != 429:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if self._exhausted_until.get(worker_domain, 0) > now:
|
||||||
|
return
|
||||||
|
exhausted_until = now + (86400 - (now % 86400))
|
||||||
|
self._exhausted_until[worker_domain] = exhausted_until
|
||||||
|
log.warning(
|
||||||
|
"CF worker %s reached its request limit, disabled for %d seconds", worker_domain, int(exhausted_until - now))
|
||||||
|
|
||||||
|
async def _quiet_close(self, ws):
|
||||||
try:
|
try:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -199,15 +337,16 @@ class _CfWorkerPool:
|
|||||||
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
||||||
return
|
return
|
||||||
|
|
||||||
for worker_domain in proxy_config.cfproxy_worker_domains:
|
worker_domains = list(proxy_config.cfproxy_worker_domains)
|
||||||
for dc, fallback_dst in cf_fallbacks.items():
|
for dc, fallback_dst in cf_fallbacks.items():
|
||||||
self._schedule_refill((dc, worker_domain), fallback_dst)
|
self._schedule_refill(dc, fallback_dst, worker_domains)
|
||||||
|
|
||||||
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self._idle.clear()
|
self._idle.clear()
|
||||||
self._refilling.clear()
|
self._refilling.clear()
|
||||||
|
self._exhausted_until.clear()
|
||||||
|
|
||||||
|
|
||||||
ws_pool = _WsPool()
|
ws_pool = _WsPool()
|
||||||
|
|||||||
+55
-15
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
|
import logging
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -8,6 +9,8 @@ import socket as _socket
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from .config import proxy_config
|
from .config import proxy_config
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
|
||||||
_st_BB = struct.Struct('>BB')
|
_st_BB = struct.Struct('>BB')
|
||||||
_st_BBH = struct.Struct('>BBH')
|
_st_BBH = struct.Struct('>BBH')
|
||||||
@@ -64,25 +67,33 @@ def set_sock_opts(transport, buffer_size):
|
|||||||
|
|
||||||
|
|
||||||
class RawWebSocket:
|
class RawWebSocket:
|
||||||
__slots__ = ('reader', 'writer', '_closed')
|
__slots__ = ('reader', 'writer', '_closed', '_frag')
|
||||||
|
|
||||||
|
OP_CONT = 0x0
|
||||||
OP_BINARY = 0x2
|
OP_BINARY = 0x2
|
||||||
OP_CLOSE = 0x8
|
OP_CLOSE = 0x8
|
||||||
OP_PING = 0x9
|
OP_PING = 0x9
|
||||||
OP_PONG = 0xA
|
OP_PONG = 0xA
|
||||||
|
|
||||||
|
MAX_MESSAGE_LEN = 16 * 1024 * 1024
|
||||||
|
|
||||||
def __init__(self, reader: asyncio.StreamReader,
|
def __init__(self, reader: asyncio.StreamReader,
|
||||||
writer: asyncio.StreamWriter):
|
writer: asyncio.StreamWriter):
|
||||||
self.reader = reader
|
self.reader = reader
|
||||||
self.writer = writer
|
self.writer = writer
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
self._frag = bytearray()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def connect(host: str, domain: str, timeout: float = 10.0,
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
path: str = '/apiws') -> 'RawWebSocket':
|
path: str = '/apiws', *,
|
||||||
|
sni: Optional[str] = None) -> 'RawWebSocket':
|
||||||
|
if sni is None:
|
||||||
|
sni = domain
|
||||||
|
|
||||||
reader, writer = await asyncio.wait_for(
|
reader, writer = await asyncio.wait_for(
|
||||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||||
server_hostname=domain),
|
server_hostname=sni),
|
||||||
timeout=min(timeout, 10))
|
timeout=min(timeout, 10))
|
||||||
|
|
||||||
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||||
@@ -99,6 +110,7 @@ class RawWebSocket:
|
|||||||
f'Sec-WebSocket-Protocol: binary\r\n'
|
f'Sec-WebSocket-Protocol: binary\r\n'
|
||||||
f'\r\n'
|
f'\r\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
writer.write(req.encode())
|
writer.write(req.encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
@@ -154,19 +166,15 @@ class RawWebSocket:
|
|||||||
self._build_frame(self.OP_BINARY, part, mask=True))
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
await self.writer.drain()
|
await self.writer.drain()
|
||||||
|
|
||||||
async def send_ping(self, payload: bytes = b''):
|
|
||||||
if self._closed:
|
|
||||||
raise ConnectionError("WebSocket closed")
|
|
||||||
frame = self._build_frame(self.OP_PING, payload, mask=True)
|
|
||||||
self.writer.write(frame)
|
|
||||||
await self.writer.drain()
|
|
||||||
|
|
||||||
async def recv(self) -> Optional[bytes]:
|
async def recv(self) -> Optional[bytes]:
|
||||||
while not self._closed:
|
while not self._closed:
|
||||||
opcode, payload = await self._read_frame()
|
opcode, payload, fin = await self._read_frame()
|
||||||
|
|
||||||
if opcode == self.OP_CLOSE:
|
if opcode == self.OP_CLOSE:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
code, reason = self._parse_close(payload)
|
||||||
|
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||||
|
code, reason)
|
||||||
try:
|
try:
|
||||||
self.writer.write(self._build_frame(
|
self.writer.write(self._build_frame(
|
||||||
self.OP_CLOSE,
|
self.OP_CLOSE,
|
||||||
@@ -188,8 +196,18 @@ class RawWebSocket:
|
|||||||
if opcode == self.OP_PONG:
|
if opcode == self.OP_PONG:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if opcode in (0x1, 0x2):
|
if opcode in (self.OP_CONT, 0x1, self.OP_BINARY):
|
||||||
|
if fin and not self._frag:
|
||||||
return payload
|
return payload
|
||||||
|
self._frag.extend(payload)
|
||||||
|
if len(self._frag) > self.MAX_MESSAGE_LEN:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"WS message too large: {len(self._frag)} bytes")
|
||||||
|
if not fin:
|
||||||
|
continue
|
||||||
|
message = bytes(self._frag)
|
||||||
|
self._frag.clear()
|
||||||
|
return message
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -209,6 +227,25 @@ class RawWebSocket:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_WS_CLOSE_REASONS = {
|
||||||
|
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
|
||||||
|
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
|
||||||
|
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
|
||||||
|
1011: 'internal_error',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
|
||||||
|
if not payload or len(payload) < 2:
|
||||||
|
return None, ''
|
||||||
|
try:
|
||||||
|
code = int.from_bytes(payload[:2], 'big')
|
||||||
|
text = payload[2:].decode('utf-8', errors='replace')
|
||||||
|
name = cls._WS_CLOSE_REASONS.get(code)
|
||||||
|
return code, f"{text} ({name})" if name else text
|
||||||
|
except Exception:
|
||||||
|
return None, ''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_frame(opcode: int, data: bytes,
|
def _build_frame(opcode: int, data: bytes,
|
||||||
mask: bool = False) -> bytes:
|
mask: bool = False) -> bytes:
|
||||||
@@ -228,17 +265,20 @@ class RawWebSocket:
|
|||||||
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
||||||
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
||||||
|
|
||||||
async def _read_frame(self) -> Tuple[int, bytes]:
|
async def _read_frame(self) -> Tuple[int, bytes, bool]:
|
||||||
hdr = await self.reader.readexactly(2)
|
hdr = await self.reader.readexactly(2)
|
||||||
|
fin = bool(hdr[0] & 0x80)
|
||||||
opcode = hdr[0] & 0x0F
|
opcode = hdr[0] & 0x0F
|
||||||
length = hdr[1] & 0x7F
|
length = hdr[1] & 0x7F
|
||||||
if length == 126:
|
if length == 126:
|
||||||
length = _st_H.unpack(await self.reader.readexactly(2))[0]
|
length = _st_H.unpack(await self.reader.readexactly(2))[0]
|
||||||
elif length == 127:
|
elif length == 127:
|
||||||
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
|
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
|
||||||
|
if length > self.MAX_MESSAGE_LEN:
|
||||||
|
raise ConnectionError(f"WS frame too large: {length} bytes")
|
||||||
if hdr[1] & 0x80:
|
if hdr[1] & 0x80:
|
||||||
mask_key = await self.reader.readexactly(4)
|
mask_key = await self.reader.readexactly(4)
|
||||||
payload = await self.reader.readexactly(length)
|
payload = await self.reader.readexactly(length)
|
||||||
return opcode, _xor_mask(payload, mask_key)
|
return opcode, _xor_mask(payload, mask_key), fin
|
||||||
payload = await self.reader.readexactly(length)
|
payload = await self.reader.readexactly(length)
|
||||||
return opcode, payload
|
return opcode, payload, fin
|
||||||
@@ -7,6 +7,7 @@ class _Stats:
|
|||||||
self.connections_ws = 0
|
self.connections_ws = 0
|
||||||
self.connections_tcp_fallback = 0
|
self.connections_tcp_fallback = 0
|
||||||
self.connections_cfproxy = 0
|
self.connections_cfproxy = 0
|
||||||
|
self.connections_fronting = 0
|
||||||
self.connections_bad = 0
|
self.connections_bad = 0
|
||||||
self.connections_masked = 0
|
self.connections_masked = 0
|
||||||
self.ws_errors = 0
|
self.ws_errors = 0
|
||||||
@@ -29,6 +30,7 @@ class _Stats:
|
|||||||
f"ws={self.connections_ws} "
|
f"ws={self.connections_ws} "
|
||||||
f"tcp_fb={self.connections_tcp_fallback} "
|
f"tcp_fb={self.connections_tcp_fallback} "
|
||||||
f"cf={self.connections_cfproxy} "
|
f"cf={self.connections_cfproxy} "
|
||||||
|
f"front={self.connections_fronting} "
|
||||||
f"bad={self.connections_bad} "
|
f"bad={self.connections_bad} "
|
||||||
f"masked={self.connections_masked} "
|
f"masked={self.connections_masked} "
|
||||||
f"err={self.ws_errors} "
|
f"err={self.ws_errors} "
|
||||||
|
|||||||
+122
-41
@@ -33,10 +33,14 @@ from ._aes import Cipher, algorithms, modes
|
|||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
DC_FAIL_COOLDOWN = 30.0
|
IP_FAIL_COOLDOWN = 3600.0
|
||||||
|
DC_FAIL_COOLDOWN = 60.0
|
||||||
WS_FAIL_TIMEOUT = 2.0
|
WS_FAIL_TIMEOUT = 2.0
|
||||||
|
LISTENER_CHECK_INTERVAL = 5.0
|
||||||
|
LISTENER_RESTART_DELAY = 1.0
|
||||||
ws_blacklist: Set[str] = set()
|
ws_blacklist: Set[str] = set()
|
||||||
dc_fail_until: Dict[str, float] = {}
|
dc_fail_until: Dict[str, float] = {}
|
||||||
|
ip_fail_until: Dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
||||||
@@ -272,6 +276,11 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
|
|
||||||
dc, is_media, proto_tag, client_dec_prekey_iv = result
|
dc, is_media, proto_tag, client_dec_prekey_iv = result
|
||||||
|
|
||||||
|
is_test_dc = proxy_config.force_test_dc or dc >= 10000
|
||||||
|
if dc >= 10000:
|
||||||
|
log.info("[%s] test DC%d -> DC%d", label, dc, dc - 10000)
|
||||||
|
dc -= 10000
|
||||||
|
|
||||||
if proto_tag == PROTO_TAG_ABRIDGED:
|
if proto_tag == PROTO_TAG_ABRIDGED:
|
||||||
proto_int = PROTO_ABRIDGED_INT
|
proto_int = PROTO_ABRIDGED_INT
|
||||||
elif proto_tag == PROTO_TAG_INTERMEDIATE:
|
elif proto_tag == PROTO_TAG_INTERMEDIATE:
|
||||||
@@ -287,17 +296,27 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
relay_init = _generate_relay_init(proto_tag, dc_idx)
|
relay_init = _generate_relay_init(proto_tag, dc_idx)
|
||||||
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
|
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
|
||||||
|
|
||||||
dc_key = f'{dc}{"m" if is_media else ""}'
|
dc_key = f'{dc}{"t" if is_test_dc else ""}{"m" if is_media else ""}'
|
||||||
media_tag = " media" if is_media else ""
|
media_tag = " media" if is_media else ""
|
||||||
|
now = time.monotonic()
|
||||||
|
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
|
||||||
|
target = proxy_config.dc_redirects.get(dc)
|
||||||
|
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
|
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
|
||||||
|
if (dc not in proxy_config.dc_redirects
|
||||||
|
or dc_key in ws_blacklist
|
||||||
|
or now < ip_fail_until.get(target, 0) and is_any_cf_fallback):
|
||||||
|
|
||||||
# Fallback if DC not in config or WS blacklisted for this DC/is_media
|
|
||||||
if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
|
|
||||||
if dc not in proxy_config.dc_redirects:
|
if dc not in proxy_config.dc_redirects:
|
||||||
log.info("[%s] DC%d not in config -> fallback",
|
log.info("[%s] DC%d not in config -> fallback",
|
||||||
label, dc)
|
label, dc)
|
||||||
else:
|
elif dc_key in ws_blacklist:
|
||||||
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
||||||
label, dc, media_tag)
|
label, dc, media_tag)
|
||||||
|
else:
|
||||||
|
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
|
||||||
|
label, dc, media_tag, target)
|
||||||
splitter = None
|
splitter = None
|
||||||
try:
|
try:
|
||||||
splitter = MsgSplitter(relay_init, proto_int)
|
splitter = MsgSplitter(relay_init, proto_int)
|
||||||
@@ -305,35 +324,38 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
pass
|
pass
|
||||||
ok = await do_fallback(
|
ok = await do_fallback(
|
||||||
clt_reader, clt_writer, relay_init, label,
|
clt_reader, clt_writer, relay_init, label,
|
||||||
dc, is_media, media_tag,
|
dc, is_test_dc, is_media, media_tag,
|
||||||
ctx, splitter=splitter)
|
ctx, splitter=splitter)
|
||||||
if not ok:
|
if not ok:
|
||||||
log.warning("[%s] DC%d%s no fallback available",
|
log.warning("[%s] DC%d%s no fallback available",
|
||||||
label, dc, media_tag)
|
label, dc, media_tag)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.monotonic()
|
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
|
||||||
fail_until = dc_fail_until.get(dc_key, 0)
|
|
||||||
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
|
|
||||||
|
|
||||||
domains = ws_domains(dc, is_media)
|
domains = ws_domains(dc, is_media)
|
||||||
target = proxy_config.dc_redirects[dc]
|
|
||||||
ws = None
|
ws = None
|
||||||
ws_failed_redirect = False
|
ws_failed_redirect = False
|
||||||
|
ws_timed_out = False
|
||||||
all_redirects = True
|
all_redirects = True
|
||||||
|
|
||||||
ws = await ws_pool.get(dc, is_media, target, domains)
|
allow_pool_refill = now >= ip_fail_until.get(target, 0)
|
||||||
|
ws = await ws_pool.get(
|
||||||
|
dc, is_media, target, domains,
|
||||||
|
allow_refill=allow_pool_refill,
|
||||||
|
) if not is_test_dc else None
|
||||||
if ws:
|
if ws:
|
||||||
log.info("[%s] DC%d%s -> pool hit via %s",
|
log.info("[%s] DC%d%s -> pool hit via %s",
|
||||||
label, dc, media_tag, target)
|
label, dc, media_tag, target)
|
||||||
else:
|
else:
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
url = f'wss://{domain}/apiws'
|
url = f'wss://{domain}{ws_path}'
|
||||||
log.info("[%s] DC%d%s -> %s via %s",
|
log.info("[%s] DC%d%s -> %s via %s",
|
||||||
label, dc, media_tag, url, target)
|
label, dc, media_tag, url, target)
|
||||||
try:
|
try:
|
||||||
ws = await RawWebSocket.connect(target, domain,
|
ws = await RawWebSocket.connect(target, domain,
|
||||||
timeout=ws_timeout)
|
timeout=ws_timeout,
|
||||||
|
path=ws_path)
|
||||||
all_redirects = False
|
all_redirects = False
|
||||||
break
|
break
|
||||||
except WsHandshakeError as exc:
|
except WsHandshakeError as exc:
|
||||||
@@ -349,6 +371,12 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
all_redirects = False
|
all_redirects = False
|
||||||
log.warning("[%s] DC%d%s WS handshake: %s",
|
log.warning("[%s] DC%d%s WS handshake: %s",
|
||||||
label, dc, media_tag, exc.status_line)
|
label, dc, media_tag, exc.status_line)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stats.ws_errors += 1
|
||||||
|
ws_timed_out = True
|
||||||
|
log.warning("[%s] DC%d%s WS connect timed out via %s",
|
||||||
|
label, dc, media_tag, domain)
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
stats.ws_errors += 1
|
stats.ws_errors += 1
|
||||||
all_redirects = False
|
all_redirects = False
|
||||||
@@ -357,6 +385,11 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
|
|
||||||
# WS failed -> fallback
|
# WS failed -> fallback
|
||||||
if ws is None:
|
if ws is None:
|
||||||
|
if ws_timed_out:
|
||||||
|
ip_fail_until[target] = now + IP_FAIL_COOLDOWN
|
||||||
|
log.info("[%s] DC%d%s WS connect to %s timed out, cooldown for %ds",
|
||||||
|
label, dc, media_tag, target, int(IP_FAIL_COOLDOWN))
|
||||||
|
|
||||||
if ws_failed_redirect and all_redirects:
|
if ws_failed_redirect and all_redirects:
|
||||||
ws_blacklist.add(dc_key)
|
ws_blacklist.add(dc_key)
|
||||||
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
|
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
|
||||||
@@ -375,7 +408,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
pass
|
pass
|
||||||
ok = await do_fallback(
|
ok = await do_fallback(
|
||||||
clt_reader, clt_writer, relay_init, label,
|
clt_reader, clt_writer, relay_init, label,
|
||||||
dc, is_media, media_tag,
|
dc, is_test_dc, is_media, media_tag,
|
||||||
ctx, splitter=splitter_fb)
|
ctx, splitter=splitter_fb)
|
||||||
if ok:
|
if ok:
|
||||||
log.info("[%s] DC%d%s fallback closed",
|
log.info("[%s] DC%d%s fallback closed",
|
||||||
@@ -383,6 +416,8 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
return
|
return
|
||||||
|
|
||||||
dc_fail_until.pop(dc_key, None)
|
dc_fail_until.pop(dc_key, None)
|
||||||
|
ip_fail_until.pop(target, None)
|
||||||
|
ws_pool.report_success(dc, is_media)
|
||||||
stats.connections_ws += 1
|
stats.connections_ws += 1
|
||||||
|
|
||||||
splitter = None
|
splitter = None
|
||||||
@@ -436,12 +471,12 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
cf_worker_pool.reset()
|
cf_worker_pool.reset()
|
||||||
ws_blacklist.clear()
|
ws_blacklist.clear()
|
||||||
dc_fail_until.clear()
|
dc_fail_until.clear()
|
||||||
|
ip_fail_until.clear()
|
||||||
_client_tasks.clear()
|
_client_tasks.clear()
|
||||||
|
|
||||||
if proxy_config.fallback_cfproxy:
|
user_cf_domains = proxy_config.cfproxy_user_domains
|
||||||
user = proxy_config.cfproxy_user_domains
|
if user_cf_domains:
|
||||||
if user:
|
balancer.update_domains_list(user_cf_domains)
|
||||||
balancer.update_domains_list(user)
|
|
||||||
else:
|
else:
|
||||||
start_cfproxy_domain_refresh()
|
start_cfproxy_domain_refresh()
|
||||||
|
|
||||||
@@ -484,7 +519,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
ip = proxy_config.dc_redirects.get(dc)
|
ip = proxy_config.dc_redirects.get(dc)
|
||||||
log.info(" DC%d: %s", dc, ip)
|
log.info(" DC%d: %s", dc, ip)
|
||||||
if proxy_config.fallback_cfproxy:
|
if proxy_config.fallback_cfproxy:
|
||||||
user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
|
user_domain = ", ".join(proxy_config.cfproxy_user_domains) if proxy_config.cfproxy_user_domains else "auto"
|
||||||
log.info(" CF proxy: enabled (%s)", user_domain)
|
log.info(" CF proxy: enabled (%s)", user_domain)
|
||||||
if proxy_config.cfproxy_worker_domains:
|
if proxy_config.cfproxy_worker_domains:
|
||||||
log.info(" CF worker: enabled (%s)",
|
log.info(" CF worker: enabled (%s)",
|
||||||
@@ -511,38 +546,83 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
await ws_pool.warmup()
|
await ws_pool.warmup()
|
||||||
await cf_worker_pool.warmup()
|
await cf_worker_pool.warmup()
|
||||||
|
|
||||||
|
async def _quiet_cancel(t):
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
try:
|
try:
|
||||||
async with server:
|
await t
|
||||||
if stop_event:
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
serve_task = asyncio.create_task(server.serve_forever())
|
serve_task = asyncio.create_task(server.serve_forever())
|
||||||
stop_task = asyncio.create_task(stop_event.wait())
|
stop_task = (asyncio.create_task(stop_event.wait())
|
||||||
|
if stop_event else None)
|
||||||
|
|
||||||
|
async def _listener_watchdog():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(LISTENER_CHECK_INTERVAL)
|
||||||
|
socks = server.sockets
|
||||||
|
if not socks or all(s.fileno() < 0 for s in socks):
|
||||||
|
return
|
||||||
|
|
||||||
|
watchdog_task = asyncio.create_task(_listener_watchdog())
|
||||||
|
waiters = [serve_task, watchdog_task]
|
||||||
|
if stop_task is not None:
|
||||||
|
waiters.append(stop_task)
|
||||||
|
|
||||||
done, _ = await asyncio.wait(
|
done, _ = await asyncio.wait(
|
||||||
(serve_task, stop_task),
|
waiters, return_when=asyncio.FIRST_COMPLETED)
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
if stop_task is not None and stop_task in done:
|
||||||
if stop_task in done:
|
for task in list(_client_tasks):
|
||||||
|
task.cancel()
|
||||||
|
if _client_tasks:
|
||||||
|
await asyncio.gather(
|
||||||
|
*_client_tasks, return_exceptions=True)
|
||||||
|
await _quiet_cancel(watchdog_task)
|
||||||
|
await _quiet_cancel(serve_task)
|
||||||
server.close()
|
server.close()
|
||||||
await server.wait_closed()
|
await server.wait_closed()
|
||||||
if not serve_task.done():
|
break
|
||||||
serve_task.cancel()
|
|
||||||
|
await _quiet_cancel(watchdog_task)
|
||||||
|
await _quiet_cancel(serve_task)
|
||||||
|
log.warning(
|
||||||
|
"Listening socket died, restarting server")
|
||||||
|
server.close()
|
||||||
try:
|
try:
|
||||||
await serve_task
|
await server.wait_closed()
|
||||||
except asyncio.CancelledError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
await asyncio.sleep(LISTENER_RESTART_DELAY)
|
||||||
stop_task.cancel()
|
|
||||||
try:
|
try:
|
||||||
await stop_task
|
server = await asyncio.start_server(
|
||||||
except asyncio.CancelledError:
|
client_cb, proxy_config.host, proxy_config.port)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("Failed to restart server: %s", repr(exc))
|
||||||
|
break
|
||||||
|
_server_instance = server
|
||||||
|
for sock in server.sockets:
|
||||||
|
try:
|
||||||
|
sock.setsockopt(
|
||||||
|
_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
else:
|
log.warning("Server restored, listening on %s:%d",
|
||||||
await server.serve_forever()
|
proxy_config.host, proxy_config.port)
|
||||||
finally:
|
finally:
|
||||||
log_stats_task.cancel()
|
log_stats_task.cancel()
|
||||||
try:
|
try:
|
||||||
await log_stats_task
|
await log_stats_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_server_instance = None
|
_server_instance = None
|
||||||
|
|
||||||
|
|
||||||
@@ -590,13 +670,14 @@ def main():
|
|||||||
metavar='DOMAIN',
|
metavar='DOMAIN',
|
||||||
help='Enable Fake TLS (ee-secret) masking with the given '
|
help='Enable Fake TLS (ee-secret) masking with the given '
|
||||||
'SNI domain, e.g. example.com')
|
'SNI domain, e.g. example.com')
|
||||||
|
ap.add_argument('--force-test-dc', action='store_true',
|
||||||
|
help='Force ALL traffic to Telegram TEST datacenters. '
|
||||||
|
'Not needed for Telegram Desktop (test DCs 10001+ '
|
||||||
|
'are detected automatically); use for clients that '
|
||||||
|
'signal test DCs as plain 1-3')
|
||||||
ap.add_argument('--proxy-protocol', action='store_true',
|
ap.add_argument('--proxy-protocol', action='store_true',
|
||||||
help='Accept PROXY protocol v1 header '
|
help='Accept PROXY protocol v1 header '
|
||||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||||
ap.add_argument('--ws-keepalive', type=float, default=30.0, metavar='SEC',
|
|
||||||
help='Seconds between WebSocket keepalive PINGs to the '
|
|
||||||
'upstream (default 30, 0 to disable). Keeps idle '
|
|
||||||
'sessions alive through NAT/firewall timeouts.')
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not args.dc_ip:
|
if not args.dc_ip:
|
||||||
@@ -633,7 +714,7 @@ def main():
|
|||||||
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
||||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||||
proxy_config.proxy_protocol = args.proxy_protocol
|
proxy_config.proxy_protocol = args.proxy_protocol
|
||||||
proxy_config.ws_keepalive_interval = max(0, args.ws_keepalive)
|
proxy_config.force_test_dc = args.force_test_dc
|
||||||
|
|
||||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||||
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
||||||
|
|||||||
+15
-2
@@ -1,6 +1,9 @@
|
|||||||
import socket as _socket
|
import socket as _socket
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import http.client
|
import http.client
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import certifi
|
||||||
|
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
from urllib.request import Request
|
from urllib.request import Request
|
||||||
@@ -43,6 +46,15 @@ DC_DEFAULT_IPS: Dict[int, str] = {
|
|||||||
203: '91.105.192.100'
|
203: '91.105.192.100'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DC_TEST_IPS: Dict[int, str] = {
|
||||||
|
1: '149.154.175.10',
|
||||||
|
2: '149.154.167.40',
|
||||||
|
3: '149.154.175.117',
|
||||||
|
}
|
||||||
|
|
||||||
|
WS_PATH = '/apiws'
|
||||||
|
WS_PATH_TEST = WS_PATH + '_test'
|
||||||
|
|
||||||
|
|
||||||
def ws_domains(dc: int, is_media) -> List[str]:
|
def ws_domains(dc: int, is_media) -> List[str]:
|
||||||
if dc == 203:
|
if dc == 203:
|
||||||
@@ -95,10 +107,11 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.do_open(_Conn, req)
|
return self.do_open(_Conn, req, context=self._context)
|
||||||
except Exception:
|
except Exception:
|
||||||
return super().https_open(req)
|
return super().https_open(req)
|
||||||
|
|
||||||
|
|
||||||
def build_github_opener() -> urllib.request.OpenerDirector:
|
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||||
return urllib.request.build_opener(_PinnedHTTPSHandler())
|
context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
return urllib.request.build_opener(_PinnedHTTPSHandler(context=context))
|
||||||
|
|||||||
+12
-4
@@ -36,6 +36,7 @@ classifiers = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyperclip==1.9.0",
|
"pyperclip==1.9.0",
|
||||||
|
"certifi",
|
||||||
|
|
||||||
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
@@ -45,9 +46,9 @@ dependencies = [
|
|||||||
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
|
"cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'",
|
||||||
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
|
"Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'",
|
||||||
|
|
||||||
"customtkinter==5.2.2; platform_system != 'Darwin'",
|
"customtkinter==5.2.2",
|
||||||
"pystray==0.19.5; platform_system != 'Darwin'",
|
"pystray==0.19.5",
|
||||||
"rumps==0.4.0; platform_system == 'Darwin'",
|
"pyobjc-framework-Cocoa>=9.0; platform_system == 'Darwin'",
|
||||||
"Pillow==12.1.0; platform_system == 'Darwin'",
|
"Pillow==12.1.0; platform_system == 'Darwin'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -72,5 +73,12 @@ packages = ["proxy", "ui", "utils"]
|
|||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "proxy/__init__.py"
|
path = "proxy/__init__.py"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
ignore = ["F403", "F405"]
|
select = ["E4", "E7", "E9", "F", "B", "C4"]
|
||||||
|
ignore = ["F403", "F405", "B023"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"macos.py" = ["E402"]
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from proxy._aes import Cipher, algorithms, modes
|
||||||
|
from proxy.bridge import MsgSplitter
|
||||||
|
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(), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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,130 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
+10
-13
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import customtkinter as ctk
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ class CtkTooltip:
|
|||||||
self.delay_ms = delay_ms
|
self.delay_ms = delay_ms
|
||||||
self.wraplength = wraplength
|
self.wraplength = wraplength
|
||||||
self._after_id: Optional[str] = None
|
self._after_id: Optional[str] = None
|
||||||
self._tip: Optional[tk.Toplevel] = None
|
self._tip: Optional[ctk.CTkToplevel] = None
|
||||||
widget.bind("<Enter>", self._schedule, add="+")
|
widget.bind("<Enter>", self._schedule, add="+")
|
||||||
widget.bind("<Leave>", self._hide, add="+")
|
widget.bind("<Leave>", self._hide, add="+")
|
||||||
widget.bind("<Button>", self._hide, add="+")
|
widget.bind("<Button>", self._hide, add="+")
|
||||||
@@ -48,27 +48,24 @@ class CtkTooltip:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
tw = tk.Toplevel(self.widget.winfo_toplevel())
|
tw = ctk.CTkToplevel(self.widget.winfo_toplevel())
|
||||||
tw.wm_overrideredirect(True)
|
tw.wm_overrideredirect(True)
|
||||||
try:
|
try:
|
||||||
tw.wm_attributes("-topmost", True)
|
tw.wm_attributes("-topmost", True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tw.configure(bg="#2b2b2b")
|
tw.configure(fg_color="#2b2b2b")
|
||||||
lbl = tk.Label(
|
lbl = ctk.CTkLabel(
|
||||||
tw,
|
tw,
|
||||||
text=self.text,
|
text=self.text,
|
||||||
justify="left",
|
justify="left",
|
||||||
wraplength=self.wraplength,
|
wraplength=self.wraplength,
|
||||||
background="#2b2b2b",
|
fg_color="#2b2b2b",
|
||||||
foreground="#f0f0f0",
|
text_color="#f0f0f0",
|
||||||
relief="flat",
|
corner_radius=0,
|
||||||
borderwidth=0,
|
font=("Segoe UI", 14) if _is_windows() else None,
|
||||||
padx=10,
|
|
||||||
pady=8,
|
|
||||||
font=("Segoe UI", 10) if _is_windows() else None,
|
|
||||||
)
|
)
|
||||||
lbl.pack()
|
lbl.pack(padx=10, pady=8)
|
||||||
x = self.widget.winfo_rootx() + 12
|
x = self.widget.winfo_rootx() + 12
|
||||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
tw.wm_geometry(f"+{x}+{y}")
|
tw.wm_geometry(f"+{x}+{y}")
|
||||||
|
|||||||
+265
-204
@@ -17,65 +17,22 @@ from ui.ctk_theme import (
|
|||||||
main_content_frame,
|
main_content_frame,
|
||||||
)
|
)
|
||||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||||
|
from ui.i18n import (
|
||||||
|
label_from_language,
|
||||||
|
language_from_label,
|
||||||
|
language_option_labels,
|
||||||
|
set_language,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
_TIP_HOST = (
|
|
||||||
"Адрес, на котором прокси принимает подключения.\n"
|
|
||||||
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
|
||||||
)
|
|
||||||
_TIP_PORT = (
|
|
||||||
"Порт прокси. В Telegram Desktop в настройках прокси должен быть "
|
|
||||||
"указан тот же порт"
|
|
||||||
)
|
|
||||||
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
|
|
||||||
_TIP_DC = (
|
|
||||||
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
|
|
||||||
"Каждая строка: «номер:IP», например 4:149.154.167.220. "
|
|
||||||
"Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n"
|
|
||||||
"Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220"
|
|
||||||
)
|
|
||||||
_TIP_VERBOSE = (
|
|
||||||
"Если включено, в файл логов пишется больше подробностей — "
|
|
||||||
"необходимо при поиске неполадок"
|
|
||||||
)
|
|
||||||
_TIP_BUF_KB = (
|
|
||||||
"Размер буфера приёма/передачи в килобайтах.\n"
|
|
||||||
"Больше значение — больше выделение памяти на сокет"
|
|
||||||
)
|
|
||||||
_TIP_POOL = (
|
|
||||||
"Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n"
|
|
||||||
"Увеличение может помочь при высокой нагрузке"
|
|
||||||
)
|
|
||||||
_TIP_LOG_MB = (
|
|
||||||
"Максимальный размер файла лога; при достижении лимита файл перезаписывается"
|
|
||||||
)
|
|
||||||
_TIP_AUTOSTART = (
|
|
||||||
"Запускать TG WS Proxy при входе в Windows. "
|
|
||||||
"Если вы переместите программу в другую папку, автозапуск сбросится"
|
|
||||||
)
|
|
||||||
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
|
|
||||||
_TIP_CFPROXY = (
|
|
||||||
"Использовать Cloudflare прокси для недоступных датацентров"
|
|
||||||
)
|
|
||||||
_TIP_CFPROXY_DOMAIN = (
|
|
||||||
"Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\n"
|
|
||||||
"Несколько доменов указывайте через запятую.\n"
|
|
||||||
"Если не указаны — выбираются автоматически из поддерживаемых доменов"
|
|
||||||
)
|
|
||||||
_TIP_CFPROXY_USER_DOMAIN_CB = (
|
|
||||||
"Указать свои домены вместо автоматического выбора"
|
|
||||||
)
|
|
||||||
_TIP_CFWORKER_DOMAIN = (
|
|
||||||
"Домены Cloudflare Worker (например, name.account.workers.dev).\n"
|
|
||||||
"Несколько доменов указывайте через запятую.\n"
|
|
||||||
"Прокси передает через них подключение к Telegram DC по IP"
|
|
||||||
)
|
|
||||||
_TIP_SAVE = "Сохранить настройки"
|
|
||||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
|
||||||
|
|
||||||
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
def _get_doc_url(doc_name: str) -> str:
|
||||||
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
from ui.i18n import get_language
|
||||||
|
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"
|
||||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
||||||
_CFWORKER_TEST_DST = {
|
_CFWORKER_TEST_DST = {
|
||||||
1: '149.154.175.50',
|
1: '149.154.175.50',
|
||||||
@@ -123,11 +80,11 @@ def _run_connectivity_test(cases: list) -> dict:
|
|||||||
if "101" in first:
|
if "101" in first:
|
||||||
results[dc] = True
|
results[dc] = True
|
||||||
else:
|
else:
|
||||||
results[dc] = first or "нет ответа"
|
results[dc] = first or t("connectivity.no_response")
|
||||||
ssock.close()
|
ssock.close()
|
||||||
raw.close()
|
raw.close()
|
||||||
except _socket.timeout:
|
except _socket.timeout:
|
||||||
results[dc] = "таймаут"
|
results[dc] = t("connectivity.timeout")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
msg = str(exc)
|
msg = str(exc)
|
||||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
results[dc] = msg[:60] if len(msg) > 60 else msg
|
||||||
@@ -183,30 +140,34 @@ def _show_connectivity_results(title_base: str, results: dict,
|
|||||||
from tkinter import messagebox as _mb
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
ok = [dc for dc, v in results.items() if v is True]
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
|
total = len(_CFPROXY_TEST_DCS)
|
||||||
if auto_mode:
|
if auto_mode:
|
||||||
if domain:
|
if domain:
|
||||||
title = f"{title_base}: доступен"
|
title = t("connectivity.available", title=title_base)
|
||||||
msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
|
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
|
||||||
else:
|
else:
|
||||||
title = f"{title_base}: недоступен"
|
title = t("connectivity.unavailable", title=title_base)
|
||||||
msg = unavailable_message
|
msg = unavailable_message
|
||||||
else:
|
else:
|
||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
if len(ok) == len(_CFPROXY_TEST_DCS):
|
if len(ok) == total:
|
||||||
title = f"{title_base}: всё работает"
|
title = t("connectivity.all_ok", title=title_base)
|
||||||
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
|
||||||
elif not ok:
|
elif not ok:
|
||||||
title = f"{title_base}: недоступен"
|
title = t("connectivity.unavailable", title=title_base)
|
||||||
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
errors = "\n".join(
|
||||||
msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||||
else:
|
for dc, v in fail
|
||||||
title = f"{title_base}: частично работает"
|
|
||||||
msg = (
|
|
||||||
f"Домен: {domain}\n\n"
|
|
||||||
f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n"
|
|
||||||
f"\u2717 Недоступны:\n"
|
|
||||||
+ "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
|
||||||
)
|
)
|
||||||
|
msg = t("connectivity.none_ok", domain=domain, errors=errors)
|
||||||
|
else:
|
||||||
|
title = t("connectivity.partial", title=title_base)
|
||||||
|
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
||||||
|
fail_list = "\n".join(
|
||||||
|
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||||
|
for dc, v in fail
|
||||||
|
)
|
||||||
|
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||||
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
@@ -232,26 +193,25 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
|||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
if len(ok) == total:
|
if len(ok) == total:
|
||||||
any_ok = True
|
any_ok = True
|
||||||
blocks.append(f"\u2713 {domain}: все {total} серверов доступны")
|
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
|
||||||
elif not ok:
|
elif not ok:
|
||||||
all_ok = False
|
all_ok = False
|
||||||
blocks.append(f"\u2717 {domain}: недоступен")
|
blocks.append(t("connectivity.multi_fail", domain=domain))
|
||||||
else:
|
else:
|
||||||
all_ok = False
|
all_ok = False
|
||||||
any_ok = True
|
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(
|
blocks.append(
|
||||||
f"~ {domain}: работают "
|
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||||
f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; "
|
|
||||||
f"недоступны "
|
|
||||||
f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if all_ok:
|
if all_ok:
|
||||||
title = f"{title_base}: всё работает"
|
title = t("connectivity.all_ok", title=title_base)
|
||||||
elif any_ok:
|
elif any_ok:
|
||||||
title = f"{title_base}: частично работает"
|
title = t("connectivity.partial", title=title_base)
|
||||||
else:
|
else:
|
||||||
title = f"{title_base}: недоступен"
|
title = t("connectivity.unavailable", title=title_base)
|
||||||
msg = "\n\n".join(blocks)
|
msg = "\n\n".join(blocks)
|
||||||
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
@@ -265,18 +225,38 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
|||||||
|
|
||||||
_INNER_W = 396
|
_INNER_W = 396
|
||||||
|
|
||||||
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
|
_APPEARANCE_KEYS = ("auto", "light", "dark")
|
||||||
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
|
|
||||||
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
|
|
||||||
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_options() -> List[str]:
|
||||||
|
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_from_cfg(value: str) -> str:
|
||||||
|
if value in _APPEARANCE_KEYS:
|
||||||
|
return t(f"appearance.{value}")
|
||||||
|
return t("appearance.auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_to_cfg(label: str) -> str:
|
||||||
|
for key in _APPEARANCE_KEYS:
|
||||||
|
if t(f"appearance.{key}") == label:
|
||||||
|
return key
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
|
||||||
|
combo.configure(values=[label for _, label in language_option_labels()])
|
||||||
|
var.set(label_from_language(cfg_value))
|
||||||
|
|
||||||
|
|
||||||
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
||||||
opts = dict(
|
opts = {
|
||||||
font=(theme.ui_font_family, 13), corner_radius=radius,
|
"font": (theme.ui_font_family, 13), "corner_radius": radius,
|
||||||
fg_color=theme.bg, border_color=theme.field_border,
|
"fg_color": theme.bg, "border_color": theme.field_border,
|
||||||
border_width=1, text_color=theme.text_primary,
|
"border_width": 1, "text_color": theme.text_primary,
|
||||||
)
|
}
|
||||||
if var is not None:
|
if var is not None:
|
||||||
opts["textvariable"] = var
|
opts["textvariable"] = var
|
||||||
if width:
|
if width:
|
||||||
@@ -335,6 +315,10 @@ def tray_settings_scroll_and_footer(
|
|||||||
scrollbar_button_hover_color=theme.text_secondary,
|
scrollbar_button_hover_color=theme.text_secondary,
|
||||||
)
|
)
|
||||||
scroll.pack(fill="both", expand=True)
|
scroll.pack(fill="both", expand=True)
|
||||||
|
try:
|
||||||
|
scroll._parent_canvas.configure(yscrollincrement=4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return scroll, footer
|
return scroll, footer
|
||||||
|
|
||||||
|
|
||||||
@@ -371,9 +355,12 @@ class TrayConfigFormWidgets:
|
|||||||
autostart_var: Optional[Any]
|
autostart_var: Optional[Any]
|
||||||
check_updates_var: Optional[Any]
|
check_updates_var: Optional[Any]
|
||||||
cfproxy_var: Optional[Any] = None
|
cfproxy_var: Optional[Any] = None
|
||||||
|
cfproxy_user_domain_enabled_var: Optional[Any] = None
|
||||||
cfproxy_user_domain_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
|
cfproxy_worker_domain_var: Optional[Any] = None
|
||||||
appearance_var: Optional[Any] = None
|
appearance_var: Optional[Any] = None
|
||||||
|
language_var: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
def install_tray_config_form(
|
def install_tray_config_form(
|
||||||
@@ -385,11 +372,15 @@ def install_tray_config_form(
|
|||||||
*,
|
*,
|
||||||
show_autostart: bool = False,
|
show_autostart: bool = False,
|
||||||
autostart_value: bool = False,
|
autostart_value: bool = False,
|
||||||
|
on_language_change: Optional[Callable[[], None]] = None,
|
||||||
) -> TrayConfigFormWidgets:
|
) -> TrayConfigFormWidgets:
|
||||||
|
lang_cfg = cfg.get("language", default_config["language"])
|
||||||
|
set_language(lang_cfg)
|
||||||
|
|
||||||
header = ctk.CTkFrame(frame, fg_color="transparent")
|
header = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
header.pack(fill="x", pady=(0, 2))
|
header.pack(fill="x", pady=(0, 2))
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
header, text="Настройки",
|
header, text=t("settings.title"),
|
||||||
font=(theme.ui_font_family, 17, "bold"),
|
font=(theme.ui_font_family, 17, "bold"),
|
||||||
text_color=theme.text_primary, anchor="w",
|
text_color=theme.text_primary, anchor="w",
|
||||||
).pack(side="left")
|
).pack(side="left")
|
||||||
@@ -398,24 +389,72 @@ def install_tray_config_form(
|
|||||||
font=(theme.ui_font_family, 12),
|
font=(theme.ui_font_family, 12),
|
||||||
text_color=theme.text_secondary, anchor="e",
|
text_color=theme.text_secondary, anchor="e",
|
||||||
).pack(side="right", padx=(4, 0))
|
).pack(side="right", padx=(4, 0))
|
||||||
|
|
||||||
appearance_var = ctk.StringVar(
|
appearance_var = ctk.StringVar(
|
||||||
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто")
|
value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_appearance_change(choice: str) -> None:
|
def _on_appearance_change(choice: str) -> None:
|
||||||
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto")
|
cfg_val = _appearance_to_cfg(choice)
|
||||||
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||||
cfg["appearance"] = cfg_val
|
cfg["appearance"] = cfg_val
|
||||||
|
|
||||||
ctk.CTkComboBox(
|
ctk.CTkButton(
|
||||||
header,
|
header, text="Donate ♥", width=90, height=28,
|
||||||
values=_APPEARANCE_OPTIONS,
|
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||||
variable=appearance_var,
|
fg_color="#22c55e", hover_color="#16a34a",
|
||||||
width=102,
|
text_color="#ffffff", border_width=0,
|
||||||
height=28,
|
command=lambda: (
|
||||||
|
header.winfo_toplevel().iconify(),
|
||||||
|
webbrowser.open(_get_doc_url("Funding")),
|
||||||
|
),
|
||||||
|
).pack(side="right", padx=(0, 6))
|
||||||
|
|
||||||
|
ui_inner = _config_section(ctk, frame, theme, t("section.interface"))
|
||||||
|
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
|
||||||
|
ui_row.pack(fill="x")
|
||||||
|
|
||||||
|
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||||
|
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||||
|
|
||||||
|
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||||
|
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||||
|
|
||||||
|
language_var = ctk.StringVar(value=label_from_language(lang_cfg))
|
||||||
|
_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
|
||||||
|
anchor="w", pady=(0, 2)
|
||||||
|
)
|
||||||
|
language_combo = ctk.CTkComboBox(
|
||||||
|
lang_col,
|
||||||
|
values=[label for _, label in language_option_labels()],
|
||||||
|
variable=language_var,
|
||||||
|
height=32,
|
||||||
font=(theme.ui_font_family, 12),
|
font=(theme.ui_font_family, 12),
|
||||||
text_color=theme.text_secondary,
|
text_color=theme.text_primary,
|
||||||
fg_color=theme.field_bg,
|
fg_color=theme.bg,
|
||||||
|
border_color=theme.field_border,
|
||||||
|
button_color=theme.field_border,
|
||||||
|
button_hover_color=theme.text_secondary,
|
||||||
|
dropdown_fg_color=theme.field_bg,
|
||||||
|
dropdown_text_color=theme.text_primary,
|
||||||
|
dropdown_hover_color=theme.field_border,
|
||||||
|
corner_radius=8,
|
||||||
|
state="readonly",
|
||||||
|
)
|
||||||
|
language_combo.pack(fill="x")
|
||||||
|
_sync_language_combobox(language_combo, language_var, lang_cfg)
|
||||||
|
|
||||||
|
_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
|
||||||
|
anchor="w", pady=(0, 2)
|
||||||
|
)
|
||||||
|
theme_combo = ctk.CTkComboBox(
|
||||||
|
theme_col,
|
||||||
|
values=_appearance_options(),
|
||||||
|
variable=appearance_var,
|
||||||
|
height=32,
|
||||||
|
font=(theme.ui_font_family, 12),
|
||||||
|
text_color=theme.text_primary,
|
||||||
|
fg_color=theme.bg,
|
||||||
border_color=theme.field_border,
|
border_color=theme.field_border,
|
||||||
button_color=theme.field_border,
|
button_color=theme.field_border,
|
||||||
button_hover_color=theme.text_secondary,
|
button_hover_color=theme.text_secondary,
|
||||||
@@ -425,35 +464,25 @@ def install_tray_config_form(
|
|||||||
corner_radius=8,
|
corner_radius=8,
|
||||||
state="readonly",
|
state="readonly",
|
||||||
command=_on_appearance_change,
|
command=_on_appearance_change,
|
||||||
).pack(side="right")
|
)
|
||||||
|
theme_combo.pack(fill="x")
|
||||||
|
|
||||||
ctk.CTkButton(
|
conn = _config_section(ctk, frame, theme, t("section.mtproto"))
|
||||||
header, text="Donate ♥", width=90, height=28,
|
|
||||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
|
||||||
fg_color="#22c55e", hover_color="#16a34a",
|
|
||||||
text_color="#ffffff", border_width=0,
|
|
||||||
command=lambda: (
|
|
||||||
header.winfo_toplevel().iconify(),
|
|
||||||
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
|
|
||||||
),
|
|
||||||
).pack(side="right", padx=(0, 6))
|
|
||||||
|
|
||||||
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
|
|
||||||
|
|
||||||
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||||
host_row.pack(fill="x")
|
host_row.pack(fill="x")
|
||||||
|
|
||||||
host_col, host_var = _labeled_entry(
|
host_col, host_var = _labeled_entry(
|
||||||
ctk, host_row, theme, "IP-адрес",
|
ctk, host_row, theme, t("label.host"),
|
||||||
cfg.get("host", default_config["host"]),
|
cfg.get("host", default_config["host"]),
|
||||||
tip=_TIP_HOST, width=160, pack_fill=True,
|
tip=t("tip.host"), width=160, pack_fill=True,
|
||||||
)
|
)
|
||||||
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
|
||||||
port_col, port_var = _labeled_entry(
|
port_col, port_var = _labeled_entry(
|
||||||
ctk, host_row, theme, "Порт",
|
ctk, host_row, theme, t("label.port"),
|
||||||
cfg.get("port", default_config["port"]),
|
cfg.get("port", default_config["port"]),
|
||||||
tip=_TIP_PORT, width=100,
|
tip=t("tip.port"), width=100,
|
||||||
)
|
)
|
||||||
port_col.pack(side="left")
|
port_col.pack(side="left")
|
||||||
|
|
||||||
@@ -461,9 +490,9 @@ def install_tray_config_form(
|
|||||||
secret_row.pack(fill="x")
|
secret_row.pack(fill="x")
|
||||||
|
|
||||||
secret_col, secret_var = _labeled_entry(
|
secret_col, secret_var = _labeled_entry(
|
||||||
ctk, secret_row, theme, "Secret",
|
ctk, secret_row, theme, t("label.secret"),
|
||||||
cfg.get("secret", default_config["secret"]),
|
cfg.get("secret", default_config["secret"]),
|
||||||
tip=_TIP_SECRET, width=160, pack_fill=True,
|
tip=t("tip.secret"), width=160, pack_fill=True,
|
||||||
)
|
)
|
||||||
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
|
||||||
@@ -478,8 +507,8 @@ def install_tray_config_form(
|
|||||||
command=lambda: secret_var.set(os.urandom(16).hex()),
|
command=lambda: secret_var.set(os.urandom(16).hex()),
|
||||||
).pack()
|
).pack()
|
||||||
|
|
||||||
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
|
dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
|
||||||
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
|
dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
||||||
dc_lbl.pack(anchor="w", pady=(0, 4))
|
dc_lbl.pack(anchor="w", pady=(0, 4))
|
||||||
dc_textbox = ctk.CTkTextbox(
|
dc_textbox = ctk.CTkTextbox(
|
||||||
dc_inner, width=_INNER_W, height=88,
|
dc_inner, width=_INNER_W, height=88,
|
||||||
@@ -489,9 +518,9 @@ def install_tray_config_form(
|
|||||||
)
|
)
|
||||||
dc_textbox.pack(fill="x")
|
dc_textbox.pack(fill="x")
|
||||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
|
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
||||||
|
|
||||||
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
|
||||||
|
|
||||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_row.pack(fill="x", pady=(0, 4))
|
cf_row.pack(fill="x", pady=(0, 4))
|
||||||
@@ -499,9 +528,9 @@ def install_tray_config_form(
|
|||||||
cfproxy_var = ctk.BooleanVar(
|
cfproxy_var = ctk.BooleanVar(
|
||||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||||
)
|
)
|
||||||
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var)
|
cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
||||||
cf_cb.pack(side="left", padx=(0, 16))
|
cf_cb.pack(side="left", padx=(0, 16))
|
||||||
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
||||||
|
|
||||||
_cf_test_btn = [None]
|
_cf_test_btn = [None]
|
||||||
|
|
||||||
@@ -512,7 +541,7 @@ def install_tray_config_form(
|
|||||||
)
|
)
|
||||||
btn = _cf_test_btn[0]
|
btn = _cf_test_btn[0]
|
||||||
if btn:
|
if btn:
|
||||||
btn.configure(text="...", state="disabled")
|
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
if user_domains:
|
if user_domains:
|
||||||
def _worker():
|
def _worker():
|
||||||
@@ -522,14 +551,14 @@ def install_tray_config_form(
|
|||||||
btn.after(
|
btn.after(
|
||||||
0,
|
0,
|
||||||
lambda: _show_multi_connectivity_results(
|
lambda: _show_multi_connectivity_results(
|
||||||
"CF-прокси", per, label_prefix='kws',
|
t("connectivity.cfproxy_title"), per, label_prefix='kws',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CF proxy test failed: %s", exc)
|
log.error("CF proxy test failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
if btn:
|
if btn:
|
||||||
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
_threading.Thread(target=_worker, daemon=True).start()
|
||||||
else:
|
else:
|
||||||
def _worker_auto():
|
def _worker_auto():
|
||||||
@@ -539,23 +568,21 @@ def install_tray_config_form(
|
|||||||
btn.after(
|
btn.after(
|
||||||
0,
|
0,
|
||||||
lambda: _show_connectivity_results(
|
lambda: _show_connectivity_results(
|
||||||
"CF-прокси", res,
|
t("connectivity.cfproxy_title"), res,
|
||||||
domain=ok_domain or '',
|
domain=ok_domain or '',
|
||||||
auto_mode=True,
|
auto_mode=True,
|
||||||
unavailable_message=(
|
unavailable_message=t("connectivity.cf_auto_fail"),
|
||||||
"\u2717 Ни один из автоматических CF-доменов не отвечает."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CF proxy auto-test failed: %s", exc)
|
log.error("CF proxy auto-test failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
if btn:
|
if btn:
|
||||||
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
||||||
_threading.Thread(target=_worker_auto, daemon=True).start()
|
_threading.Thread(target=_worker_auto, daemon=True).start()
|
||||||
|
|
||||||
_cf_test_widget = ctk.CTkButton(
|
_cf_test_widget = ctk.CTkButton(
|
||||||
cf_row, text="Тест", width=56, height=28,
|
cf_row, text=t("button.test"), width=56, height=28,
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
@@ -570,17 +597,19 @@ def install_tray_config_form(
|
|||||||
saved_user_domains = coerce_domain_list(
|
saved_user_domains = coerce_domain_list(
|
||||||
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
)
|
)
|
||||||
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
|
cf_custom_cb_var = ctk.BooleanVar(
|
||||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
|
||||||
|
)
|
||||||
|
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
||||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||||
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
|
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||||
|
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
cf_custom_row, text="?", width=28, height=32,
|
cf_custom_row, text="?", width=28, height=32,
|
||||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
||||||
@@ -589,38 +618,45 @@ def install_tray_config_form(
|
|||||||
height=32, radius=8,
|
height=32, radius=8,
|
||||||
)
|
)
|
||||||
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN)
|
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
|
||||||
|
|
||||||
def _sync_domain_entry(*_):
|
def _sync_domain_entry(*_):
|
||||||
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||||
cf_domain_entry.configure(state=state)
|
cf_domain_entry.configure(state=state)
|
||||||
if not cf_custom_cb_var.get():
|
|
||||||
cfproxy_user_domain_var.set("")
|
|
||||||
|
|
||||||
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||||
_sync_domain_entry()
|
_sync_domain_entry()
|
||||||
|
|
||||||
cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker")
|
cf_worker_inner = _config_section(ctk, frame, theme, t("section.cfworker"))
|
||||||
|
|
||||||
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
cf_worker_row.pack(fill="x", pady=(0, 4))
|
cf_worker_row.pack(fill="x", pady=(0, 4))
|
||||||
cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домены (через запятую)", size=11)
|
cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
|
||||||
cf_worker_lbl.pack(anchor="w", pady=(0, 2))
|
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 = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
cf_worker_input.pack(fill="x")
|
cf_worker_input.pack(fill="x")
|
||||||
|
|
||||||
cfproxy_worker_domain_var = ctk.StringVar(
|
saved_worker_domains = coerce_domain_list(
|
||||||
value=", ".join(coerce_domain_list(
|
|
||||||
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||||
))
|
|
||||||
)
|
)
|
||||||
|
cfproxy_worker_enabled_var = ctk.BooleanVar(
|
||||||
|
value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
|
||||||
|
)
|
||||||
|
cf_worker_cb = _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(value=", ".join(saved_worker_domains))
|
||||||
cf_worker_entry = _entry(
|
cf_worker_entry = _entry(
|
||||||
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||||
height=32, radius=8,
|
height=32, radius=8,
|
||||||
)
|
)
|
||||||
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], _TIP_CFWORKER_DOMAIN)
|
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
|
||||||
|
|
||||||
_cfworker_test_btn = [None]
|
_cfworker_test_btn = [None]
|
||||||
|
|
||||||
@@ -628,15 +664,18 @@ def install_tray_config_form(
|
|||||||
btn = _cfworker_test_btn[0]
|
btn = _cfworker_test_btn[0]
|
||||||
if btn is None:
|
if btn is None:
|
||||||
return
|
return
|
||||||
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
enabled = (
|
||||||
|
cfproxy_worker_enabled_var.get()
|
||||||
|
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||||
|
)
|
||||||
btn.configure(state="normal" if enabled else "disabled")
|
btn.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
def _on_cfworker_test():
|
def _on_cfworker_test():
|
||||||
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||||
btn = _cfworker_test_btn[0]
|
btn = _cfworker_test_btn[0]
|
||||||
if not domains or btn is None:
|
if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
|
||||||
return
|
return
|
||||||
btn.configure(text="...", state="disabled")
|
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
@@ -645,13 +684,13 @@ def install_tray_config_form(
|
|||||||
btn.after(
|
btn.after(
|
||||||
0,
|
0,
|
||||||
lambda: _show_multi_connectivity_results(
|
lambda: _show_multi_connectivity_results(
|
||||||
"CF Worker", per, label_prefix='DC',
|
t("connectivity.cfworker_title"), per, label_prefix='DC',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CF worker test failed: %s", exc)
|
log.error("CF worker test failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
btn.after(0, lambda: btn.configure(text="Тест"))
|
btn.after(0, lambda: btn.configure(text=t("button.test")))
|
||||||
btn.after(0, _sync_cfworker_test_button)
|
btn.after(0, _sync_cfworker_test_button)
|
||||||
|
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
_threading.Thread(target=_worker, daemon=True).start()
|
||||||
@@ -661,35 +700,42 @@ def install_tray_config_form(
|
|||||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=lambda: webbrowser.open(_CFWORKER_HELP_URL),
|
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
_cfworker_test_widget = ctk.CTkButton(
|
_cfworker_test_widget = ctk.CTkButton(
|
||||||
cf_worker_input, text="Тест", width=56, height=32,
|
cf_worker_row, text=t("button.test"), width=56, height=28,
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
command=_on_cfworker_test,
|
command=_on_cfworker_test,
|
||||||
)
|
)
|
||||||
_cfworker_test_widget.pack(side="right", padx=(0, 6))
|
_cfworker_test_widget.pack(side="right")
|
||||||
_cfworker_test_btn[0] = _cfworker_test_widget
|
_cfworker_test_btn[0] = _cfworker_test_widget
|
||||||
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
|
||||||
|
def _sync_cfworker_entry(*_):
|
||||||
|
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
|
||||||
|
cf_worker_entry.configure(state=state)
|
||||||
_sync_cfworker_test_button()
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
|
||||||
|
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||||
|
_sync_cfworker_entry()
|
||||||
|
|
||||||
|
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
|
||||||
|
|
||||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
||||||
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
|
verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
||||||
verbose_cb.pack(anchor="w", pady=(0, 6))
|
verbose_cb.pack(anchor="w", pady=(0, 6))
|
||||||
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
|
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
||||||
|
|
||||||
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
||||||
adv_frame.pack(fill="x")
|
adv_frame.pack(fill="x")
|
||||||
|
|
||||||
adv_rows = [
|
adv_rows = [
|
||||||
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB),
|
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
||||||
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
|
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
||||||
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
|
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
||||||
]
|
]
|
||||||
for label_text, key, tip in adv_rows:
|
for label_text, key, tip in adv_rows:
|
||||||
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
||||||
@@ -706,38 +752,32 @@ def install_tray_config_form(
|
|||||||
adv_entries = list(adv_frame.winfo_children())
|
adv_entries = list(adv_frame.winfo_children())
|
||||||
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
|
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
|
||||||
|
|
||||||
upd_inner = _config_section(ctk, frame, theme, "Обновления")
|
upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
|
||||||
st = get_status()
|
st = get_status()
|
||||||
check_updates_var = ctk.BooleanVar(
|
check_updates_var = ctk.BooleanVar(
|
||||||
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||||
)
|
)
|
||||||
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
|
upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
||||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||||
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
|
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||||
|
|
||||||
if st.get("error"):
|
if st.get("error"):
|
||||||
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
|
upd_status = t("updates.status_error")
|
||||||
elif not st.get("checked"):
|
elif not st.get("checked"):
|
||||||
upd_status = "Статус появится после фоновой проверки при запуске."
|
upd_status = t("updates.status_pending")
|
||||||
elif st.get("has_update") and st.get("latest"):
|
elif st.get("has_update") and st.get("latest"):
|
||||||
upd_status = (
|
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||||
f"На GitHub доступна версия {st['latest']} "
|
|
||||||
f"(у вас {__version__})."
|
|
||||||
)
|
|
||||||
elif st.get("ahead_of_release") and st.get("latest"):
|
elif st.get("ahead_of_release") and st.get("latest"):
|
||||||
upd_status = (
|
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||||
f"У вас {__version__} — новее последнего релиза на GitHub "
|
|
||||||
f"({st['latest']})."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
upd_status = "Установлена последняя известная версия с GitHub."
|
upd_status = t("updates.status_latest")
|
||||||
|
|
||||||
_label(ctk, upd_inner, theme, upd_status, size=11,
|
_label(ctk, upd_inner, theme, upd_status, size=11,
|
||||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
||||||
|
|
||||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
upd_inner, text="Открыть страницу релиза", height=32,
|
upd_inner, text=t("button.open_release"), height=32,
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||||
text_color=theme.text_primary, border_width=1,
|
text_color=theme.text_primary, border_width=1,
|
||||||
@@ -747,17 +787,17 @@ def install_tray_config_form(
|
|||||||
|
|
||||||
autostart_var = None
|
autostart_var = None
|
||||||
if show_autostart:
|
if show_autostart:
|
||||||
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
|
sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
||||||
autostart_var = ctk.BooleanVar(value=autostart_value)
|
autostart_var = ctk.BooleanVar(value=autostart_value)
|
||||||
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
|
as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
||||||
as_cb.pack(anchor="w", pady=(0, 4))
|
as_cb.pack(anchor="w", pady=(0, 4))
|
||||||
as_hint = _label(
|
as_hint = _label(
|
||||||
ctk, sys_inner, theme,
|
ctk, sys_inner, theme,
|
||||||
"Если переместить программу в другую папку, запись автозапуска может сброситься.",
|
t("label.autostart_hint"),
|
||||||
size=11, justify="left", wraplength=_INNER_W,
|
size=11, justify="left", wraplength=_INNER_W,
|
||||||
)
|
)
|
||||||
as_hint.pack(anchor="w")
|
as_hint.pack(anchor="w")
|
||||||
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
|
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
||||||
|
|
||||||
return TrayConfigFormWidgets(
|
return TrayConfigFormWidgets(
|
||||||
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
||||||
@@ -765,9 +805,12 @@ def install_tray_config_form(
|
|||||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||||
cfproxy_var=cfproxy_var,
|
cfproxy_var=cfproxy_var,
|
||||||
|
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
|
||||||
cfproxy_user_domain_var=cfproxy_user_domain_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,
|
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||||
appearance_var=appearance_var,
|
appearance_var=appearance_var,
|
||||||
|
language_var=language_var,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -788,6 +831,16 @@ def merge_adv_from_form(
|
|||||||
base[key] = default_config[key]
|
base[key] = default_config[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _dc_validation_message(error: ValueError) -> str:
|
||||||
|
exc_entry = getattr(error, "entry", None)
|
||||||
|
if exc_entry is None:
|
||||||
|
return str(error)
|
||||||
|
kind = getattr(error, "kind", "invalid")
|
||||||
|
if kind == "format":
|
||||||
|
return t("validation.dc_format", entry=exc_entry)
|
||||||
|
return t("validation.dc_invalid", entry=exc_entry)
|
||||||
|
|
||||||
|
|
||||||
def validate_config_form(
|
def validate_config_form(
|
||||||
widgets: TrayConfigFormWidgets,
|
widgets: TrayConfigFormWidgets,
|
||||||
default_config: dict,
|
default_config: dict,
|
||||||
@@ -800,14 +853,14 @@ def validate_config_form(
|
|||||||
try:
|
try:
|
||||||
_sock.inet_aton(host_val)
|
_sock.inet_aton(host_val)
|
||||||
except OSError:
|
except OSError:
|
||||||
return "Некорректный IP-адрес."
|
return t("validation.bad_host")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
port_val = int(widgets.port_var.get().strip())
|
port_val = int(widgets.port_var.get().strip())
|
||||||
if not (1 <= port_val <= 65535):
|
if not (1 <= port_val <= 65535):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Порт должен быть числом 1-65535"
|
return t("validation.bad_port")
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
line.strip()
|
line.strip()
|
||||||
@@ -817,15 +870,15 @@ def validate_config_form(
|
|||||||
try:
|
try:
|
||||||
parse_dc_ip_list(lines)
|
parse_dc_ip_list(lines)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return str(e)
|
return _dc_validation_message(e)
|
||||||
|
|
||||||
secret_val = widgets.secret_var.get().strip()
|
secret_val = widgets.secret_var.get().strip()
|
||||||
if len(secret_val) != 32:
|
if len(secret_val) != 32:
|
||||||
return "Secret должен содержать ровно 32 hex-символа (16 байт)."
|
return t("validation.bad_secret_len")
|
||||||
try:
|
try:
|
||||||
bytes.fromhex(secret_val)
|
bytes.fromhex(secret_val)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Secret должен состоять только из hex-символов (0-9, a-f)."
|
return t("validation.bad_secret_hex")
|
||||||
|
|
||||||
new_cfg: Dict[str, Any] = {
|
new_cfg: Dict[str, Any] = {
|
||||||
"host": host_val,
|
"host": host_val,
|
||||||
@@ -846,12 +899,20 @@ def validate_config_form(
|
|||||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||||
if widgets.cfproxy_var is not None:
|
if widgets.cfproxy_var is not None:
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
|
if widgets.cfproxy_user_domain_enabled_var is not None:
|
||||||
|
new_cfg["cfproxy_user_domain_enabled"] = bool(
|
||||||
|
widgets.cfproxy_user_domain_enabled_var.get()
|
||||||
|
)
|
||||||
if widgets.cfproxy_user_domain_var is not None:
|
if widgets.cfproxy_user_domain_var is not None:
|
||||||
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
||||||
|
if widgets.cfproxy_worker_enabled_var is not None:
|
||||||
|
new_cfg["cfproxy_worker_enabled"] = bool(widgets.cfproxy_worker_enabled_var.get())
|
||||||
if widgets.cfproxy_worker_domain_var is not None:
|
if widgets.cfproxy_worker_domain_var is not None:
|
||||||
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
|
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
|
||||||
if widgets.appearance_var is not None:
|
if widgets.appearance_var is not None:
|
||||||
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
|
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
|
||||||
|
if widgets.language_var is not None:
|
||||||
|
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
|
||||||
return new_cfg
|
return new_cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -872,22 +933,22 @@ def install_tray_config_buttons(
|
|||||||
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
btn_frame.pack(fill="x", pady=(0, 0))
|
btn_frame.pack(fill="x", pady=(0, 0))
|
||||||
save_btn = ctk.CTkButton(
|
save_btn = ctk.CTkButton(
|
||||||
btn_frame, text="Сохранить", height=38,
|
btn_frame, text=t("button.save"), height=38,
|
||||||
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
|
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff",
|
text_color="#ffffff",
|
||||||
command=on_save)
|
command=on_save)
|
||||||
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||||
attach_ctk_tooltip(save_btn, _TIP_SAVE)
|
attach_ctk_tooltip(save_btn, t("tip.save"))
|
||||||
cancel_btn = ctk.CTkButton(
|
cancel_btn = ctk.CTkButton(
|
||||||
btn_frame, text="Отмена", height=38,
|
btn_frame, text=t("button.cancel"), height=38,
|
||||||
font=(theme.ui_font_family, 14), corner_radius=10,
|
font=(theme.ui_font_family, 14), corner_radius=10,
|
||||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||||
text_color=theme.text_primary, border_width=1,
|
text_color=theme.text_primary, border_width=1,
|
||||||
border_color=theme.field_border,
|
border_color=theme.field_border,
|
||||||
command=on_cancel)
|
command=on_cancel)
|
||||||
cancel_btn.pack(side="right", fill="x", expand=True)
|
cancel_btn.pack(side="right", fill="x", expand=True)
|
||||||
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL)
|
attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
|
||||||
|
|
||||||
|
|
||||||
def populate_first_run_window(
|
def populate_first_run_window(
|
||||||
@@ -912,19 +973,19 @@ def populate_first_run_window(
|
|||||||
width=4, height=32, corner_radius=2)
|
width=4, height=32, corner_radius=2)
|
||||||
accent_bar.pack(side="left", padx=(0, 12))
|
accent_bar.pack(side="left", padx=(0, 12))
|
||||||
|
|
||||||
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
|
ctk.CTkLabel(title_frame, text=t("first_run.title"),
|
||||||
font=(theme.ui_font_family, 17, "bold"),
|
font=(theme.ui_font_family, 17, "bold"),
|
||||||
text_color=theme.text_primary).pack(side="left")
|
text_color=theme.text_primary).pack(side="left")
|
||||||
|
|
||||||
sections = [
|
sections = [
|
||||||
("Как подключить Telegram Desktop:", True),
|
(t("first_run.how_to"), True),
|
||||||
(" Автоматически:", True),
|
(t("first_run.auto"), True),
|
||||||
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
|
(t("first_run.auto_hint"), False),
|
||||||
(f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False),
|
(t("first_run.auto_link", url=tg_url), False),
|
||||||
("\n Вручную:", True),
|
("\n" + t("first_run.manual"), True),
|
||||||
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
|
(t("first_run.manual_path"), False),
|
||||||
(f" MTProto → {link_host} : {port}", False),
|
(t("first_run.manual_mtproto", host=link_host, port=port), False),
|
||||||
(f" Secret: dd{secret}", False),
|
(t("first_run.manual_secret", secret=secret), False),
|
||||||
]
|
]
|
||||||
|
|
||||||
textbox = ctk.CTkTextbox(
|
textbox = ctk.CTkTextbox(
|
||||||
@@ -956,13 +1017,13 @@ def populate_first_run_window(
|
|||||||
corner_radius=0).pack(fill="x", pady=(0, 12))
|
corner_radius=0).pack(fill="x", pady=(0, 12))
|
||||||
|
|
||||||
auto_var = ctk.BooleanVar(value=True)
|
auto_var = ctk.BooleanVar(value=True)
|
||||||
_checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас",
|
_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
||||||
auto_var).pack(anchor="w", pady=(0, 16))
|
auto_var).pack(anchor="w", pady=(0, 16))
|
||||||
|
|
||||||
def on_ok():
|
def on_ok():
|
||||||
on_done(auto_var.get())
|
on_done(auto_var.get())
|
||||||
|
|
||||||
ctk.CTkButton(frame, text="Начать", width=180, height=42,
|
ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
|
||||||
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
|
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
text_color="#ffffff",
|
text_color="#ffffff",
|
||||||
|
|||||||
@@ -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())
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"app.name": "TG WS Proxy",
|
||||||
|
"app.error_title": "TG WS Proxy — Error",
|
||||||
|
"app.settings_title": "TG WS Proxy — Settings",
|
||||||
|
"app.update_title": "TG WS Proxy — Update",
|
||||||
|
|
||||||
|
"language.ru": "Русский",
|
||||||
|
"language.en": "English",
|
||||||
|
|
||||||
|
"appearance.auto": "Auto",
|
||||||
|
"appearance.light": "Light",
|
||||||
|
"appearance.dark": "Dark",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.theme": "Theme",
|
||||||
|
|
||||||
|
"section.interface": "Interface",
|
||||||
|
"section.mtproto": "MTProto Connection",
|
||||||
|
"section.dc": "Telegram Data Centers (DC → IP)",
|
||||||
|
"section.cfproxy": "Cloudflare Proxy",
|
||||||
|
"section.cfworker": "Cloudflare Worker",
|
||||||
|
"section.logs": "Logs & Performance",
|
||||||
|
"section.updates": "Updates",
|
||||||
|
"section.windows_startup": "Windows Startup",
|
||||||
|
|
||||||
|
"label.host": "IP address",
|
||||||
|
"label.port": "Port",
|
||||||
|
"label.secret": "Secret",
|
||||||
|
"label.dc_hint": "One rule per line, format: number:IP",
|
||||||
|
"label.cf_enable": "Enable CF proxy",
|
||||||
|
"label.cf_custom_domain": "Custom domain",
|
||||||
|
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
|
||||||
|
"label.verbose": "Verbose logging",
|
||||||
|
"label.buf_kb": "Buffer, KB (default 256)",
|
||||||
|
"label.pool_size": "WebSocket session pool (default 4)",
|
||||||
|
"label.log_max_mb": "Max log size, MB (default 5)",
|
||||||
|
"label.check_updates": "Check for updates on startup",
|
||||||
|
"label.autostart": "Start on system boot",
|
||||||
|
"label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.",
|
||||||
|
|
||||||
|
"tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces",
|
||||||
|
"tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port",
|
||||||
|
"tip.secret": "Secret key for client authorization",
|
||||||
|
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
|
||||||
|
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
|
||||||
|
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
|
||||||
|
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
|
||||||
|
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
|
||||||
|
"tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset",
|
||||||
|
"tip.check_updates": "Check for updates on startup",
|
||||||
|
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
|
||||||
|
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
|
||||||
|
"tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection",
|
||||||
|
"tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
|
||||||
|
"tip.save": "Save settings",
|
||||||
|
"tip.cancel": "Close without saving changes",
|
||||||
|
|
||||||
|
"button.save": "Save",
|
||||||
|
"button.cancel": "Cancel",
|
||||||
|
"button.test": "Test",
|
||||||
|
"button.test_loading": "...",
|
||||||
|
"button.open_release": "Open release page",
|
||||||
|
"button.start": "Get started",
|
||||||
|
"button.update": "Update",
|
||||||
|
"button.page": "Page",
|
||||||
|
"button.close": "Close",
|
||||||
|
|
||||||
|
"validation.bad_host": "Invalid IP address.",
|
||||||
|
"validation.bad_port": "Port must be a number between 1 and 65535",
|
||||||
|
"validation.bad_secret_len": "Secret must be exactly 32 hex characters (16 bytes).",
|
||||||
|
"validation.bad_secret_hex": "Secret must contain only hex characters (0-9, a-f).",
|
||||||
|
"validation.dc_format": "Invalid DC:IP format: {entry}",
|
||||||
|
"validation.dc_invalid": "Invalid DC:IP entry: {entry}",
|
||||||
|
|
||||||
|
"connectivity.cfproxy_title": "CF Proxy",
|
||||||
|
"connectivity.cfworker_title": "CF Worker",
|
||||||
|
"connectivity.timeout": "timeout",
|
||||||
|
"connectivity.no_response": "no response",
|
||||||
|
"connectivity.available": "{title}: available",
|
||||||
|
"connectivity.unavailable": "{title}: unavailable",
|
||||||
|
"connectivity.all_ok": "{title}: all working",
|
||||||
|
"connectivity.partial": "{title}: partially working",
|
||||||
|
"connectivity.auto_ok": "✓ {title} works. {ok} of {total} servers reachable.",
|
||||||
|
"connectivity.all_ok_domain": "✓ All {total} servers reachable via {domain}.",
|
||||||
|
"connectivity.none_ok": "✗ No servers respond via {domain}.\n\nErrors:\n{errors}",
|
||||||
|
"connectivity.partial_detail": "Domain: {domain}\n\n✓ Working: {ok_list}\n\n✗ Unreachable:\n{fail_list}",
|
||||||
|
"connectivity.error_line": " {prefix}{dc}: {error}",
|
||||||
|
"connectivity.cf_auto_fail": "✗ None of the automatic CF domains respond.",
|
||||||
|
"connectivity.multi_all_ok": "✓ {domain}: all {total} servers reachable",
|
||||||
|
"connectivity.multi_fail": "✗ {domain}: unavailable",
|
||||||
|
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
|
||||||
|
|
||||||
|
"updates.status_error": "Could not reach GitHub. Check your network.",
|
||||||
|
"updates.status_pending": "Status will appear after the background check on startup.",
|
||||||
|
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
|
||||||
|
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
|
||||||
|
"updates.status_latest": "Latest known version from GitHub is installed.",
|
||||||
|
|
||||||
|
"first_run.title": "Proxy is running in the system tray",
|
||||||
|
"first_run.how_to": "How to connect Telegram Desktop:",
|
||||||
|
"first_run.auto": " Automatically:",
|
||||||
|
"first_run.auto_hint": " Right-click tray icon → «Open in Telegram»",
|
||||||
|
"first_run.auto_link": " Or copy the link, send it to yourself in TG and click it: {url}",
|
||||||
|
"first_run.manual": " Manually:",
|
||||||
|
"first_run.manual_path": " Settings → Advanced → Connection type → Proxy",
|
||||||
|
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||||
|
"first_run.manual_secret": " Secret: dd{secret}",
|
||||||
|
"first_run.open_now": "Open proxy in Telegram now",
|
||||||
|
|
||||||
|
"tray.open_telegram": "Open in Telegram ({host}:{port})",
|
||||||
|
"tray.copy_link": "Copy link",
|
||||||
|
"tray.restart": "Restart proxy",
|
||||||
|
"tray.settings": "Settings...",
|
||||||
|
"tray.logs": "Open logs",
|
||||||
|
"tray.exit": "Exit",
|
||||||
|
|
||||||
|
"dialog.restart_title": "Restart?",
|
||||||
|
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
|
||||||
|
"dialog.already_running": "Application is already running.",
|
||||||
|
"dialog.log_not_found": "Log file has not been created yet.",
|
||||||
|
"dialog.ctk_missing": "customtkinter is not installed.",
|
||||||
|
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
||||||
|
"dialog.copy_fail": "Failed to copy link:\n{error}",
|
||||||
|
"dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}",
|
||||||
|
"dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
|
||||||
|
"dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}",
|
||||||
|
"dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.",
|
||||||
|
"dialog.log_open_fail": "Failed to open log file:\n{error}",
|
||||||
|
"dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}",
|
||||||
|
|
||||||
|
"update.available": "New version available: {version}",
|
||||||
|
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
|
||||||
|
"update.downloading": "Downloading...",
|
||||||
|
"update.replacing": "Replacing file...",
|
||||||
|
"update.restarting": "Restarting...",
|
||||||
|
"update.error": "Error: {msg}",
|
||||||
|
"update.download_fail": "Download failed:\n{error}",
|
||||||
|
"update.rename_fail": "Failed to rename file:\n{error}",
|
||||||
|
"update.move_fail": "Failed to move file:\n{error}",
|
||||||
|
|
||||||
|
"error.dc_config": "DC → IP configuration error.",
|
||||||
|
|
||||||
|
"diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.",
|
||||||
|
"diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 10000–50000 in settings, check firewall/antivirus, and restart.",
|
||||||
|
"diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.",
|
||||||
|
|
||||||
|
"ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once."
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"app.name": "TG WS Proxy",
|
||||||
|
"app.error_title": "TG WS Proxy — Ошибка",
|
||||||
|
"app.settings_title": "TG WS Proxy — Настройки",
|
||||||
|
"app.update_title": "TG WS Proxy — обновление",
|
||||||
|
|
||||||
|
"language.ru": "Русский",
|
||||||
|
"language.en": "English",
|
||||||
|
|
||||||
|
"appearance.auto": "Авто",
|
||||||
|
"appearance.light": "Светлая",
|
||||||
|
"appearance.dark": "Тёмная",
|
||||||
|
|
||||||
|
"settings.title": "Настройки",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.theme": "Тема",
|
||||||
|
|
||||||
|
"section.interface": "Интерфейс",
|
||||||
|
"section.mtproto": "Подключение MTProto",
|
||||||
|
"section.dc": "Датацентры Telegram (DC → IP)",
|
||||||
|
"section.cfproxy": "Cloudflare Proxy",
|
||||||
|
"section.cfworker": "Cloudflare Worker",
|
||||||
|
"section.logs": "Логи и производительность",
|
||||||
|
"section.updates": "Обновления",
|
||||||
|
"section.windows_startup": "Запуск Windows",
|
||||||
|
|
||||||
|
"label.host": "IP-адрес",
|
||||||
|
"label.port": "Порт",
|
||||||
|
"label.secret": "Secret",
|
||||||
|
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
|
||||||
|
"label.cf_enable": "Включить CF-прокси",
|
||||||
|
"label.cf_custom_domain": "Свой домен",
|
||||||
|
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
|
||||||
|
"label.verbose": "Подробное логирование (verbose)",
|
||||||
|
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
|
||||||
|
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
|
||||||
|
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
|
||||||
|
"label.check_updates": "Проверять обновления при запуске",
|
||||||
|
"label.autostart": "Автозапуск при включении компьютера",
|
||||||
|
"label.autostart_hint": "Если переместить программу в другую папку, запись автозапуска может сброситься.",
|
||||||
|
|
||||||
|
"tip.host": "Адрес, на котором прокси принимает подключения.\nОбычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы",
|
||||||
|
"tip.port": "Порт прокси. В Telegram Desktop в настройках прокси должен быть указан тот же порт",
|
||||||
|
"tip.secret": "Секретный ключ для авторизации клиентов",
|
||||||
|
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
|
||||||
|
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
|
||||||
|
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
|
||||||
|
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
|
||||||
|
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
|
||||||
|
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
|
||||||
|
"tip.check_updates": "При запуске проверять наличие обновлений",
|
||||||
|
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
|
||||||
|
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
|
||||||
|
"tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
|
||||||
|
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
|
||||||
|
"tip.save": "Сохранить настройки",
|
||||||
|
"tip.cancel": "Закрыть окно без сохранения изменений",
|
||||||
|
|
||||||
|
"button.save": "Сохранить",
|
||||||
|
"button.cancel": "Отмена",
|
||||||
|
"button.test": "Тест",
|
||||||
|
"button.test_loading": "...",
|
||||||
|
"button.open_release": "Открыть страницу релиза",
|
||||||
|
"button.start": "Начать",
|
||||||
|
"button.update": "Обновить",
|
||||||
|
"button.page": "Страница",
|
||||||
|
"button.close": "Закрыть",
|
||||||
|
|
||||||
|
"validation.bad_host": "Некорректный IP-адрес.",
|
||||||
|
"validation.bad_port": "Порт должен быть числом 1-65535",
|
||||||
|
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
|
||||||
|
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
|
||||||
|
"validation.dc_format": "Неверный формат DC:IP: {entry}",
|
||||||
|
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
|
||||||
|
|
||||||
|
"connectivity.cfproxy_title": "CF-прокси",
|
||||||
|
"connectivity.cfworker_title": "CF Worker",
|
||||||
|
"connectivity.timeout": "таймаут",
|
||||||
|
"connectivity.no_response": "нет ответа",
|
||||||
|
"connectivity.available": "{title}: доступен",
|
||||||
|
"connectivity.unavailable": "{title}: недоступен",
|
||||||
|
"connectivity.all_ok": "{title}: всё работает",
|
||||||
|
"connectivity.partial": "{title}: частично работает",
|
||||||
|
"connectivity.auto_ok": "✓ {title} работает. {ok} из {total} серверов доступны.",
|
||||||
|
"connectivity.all_ok_domain": "✓ Все {total} серверов доступны через {domain}.",
|
||||||
|
"connectivity.none_ok": "✗ Ни один сервер не отвечает через {domain}.\n\nОшибки:\n{errors}",
|
||||||
|
"connectivity.partial_detail": "Домен: {domain}\n\n✓ Работают: {ok_list}\n\n✗ Недоступны:\n{fail_list}",
|
||||||
|
"connectivity.error_line": " {prefix}{dc}: {error}",
|
||||||
|
"connectivity.cf_auto_fail": "✗ Ни один из автоматических CF-доменов не отвечает.",
|
||||||
|
"connectivity.multi_all_ok": "✓ {domain}: все {total} серверов доступны",
|
||||||
|
"connectivity.multi_fail": "✗ {domain}: недоступен",
|
||||||
|
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
|
||||||
|
|
||||||
|
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
|
||||||
|
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
|
||||||
|
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
|
||||||
|
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
|
||||||
|
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
|
||||||
|
|
||||||
|
"first_run.title": "Прокси запущен и работает в системном трее",
|
||||||
|
"first_run.how_to": "Как подключить Telegram Desktop:",
|
||||||
|
"first_run.auto": " Автоматически:",
|
||||||
|
"first_run.auto_hint": " ПКМ по иконке в трее → «Открыть в Telegram»",
|
||||||
|
"first_run.auto_link": " Или скопировать ссылку, отправить её себе в TG и нажать по ней: {url}",
|
||||||
|
"first_run.manual": " Вручную:",
|
||||||
|
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
|
||||||
|
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||||
|
"first_run.manual_secret": " Secret: dd{secret}",
|
||||||
|
"first_run.open_now": "Открыть прокси в Telegram сейчас",
|
||||||
|
|
||||||
|
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
|
||||||
|
"tray.copy_link": "Скопировать ссылку",
|
||||||
|
"tray.restart": "Перезапустить прокси",
|
||||||
|
"tray.settings": "Настройки...",
|
||||||
|
"tray.logs": "Открыть логи",
|
||||||
|
"tray.exit": "Выход",
|
||||||
|
|
||||||
|
"dialog.restart_title": "Перезапустить?",
|
||||||
|
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||||
|
"dialog.already_running": "Приложение уже запущено.",
|
||||||
|
"dialog.log_not_found": "Файл логов ещё не создан.",
|
||||||
|
"dialog.ctk_missing": "customtkinter не установлен.",
|
||||||
|
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
||||||
|
"dialog.copy_fail": "Не удалось скопировать ссылку:\n{error}",
|
||||||
|
"dialog.open_tg_fail": "Не удалось открыть Telegram автоматически.\n\n{detail}",
|
||||||
|
"dialog.open_tg_fail_clipboard": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
|
||||||
|
"dialog.open_tg_fail_manual": "Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}",
|
||||||
|
"dialog.pyperclip_missing": "Установите пакет pyperclip для копирования в буфер обмена.",
|
||||||
|
"dialog.log_open_fail": "Не удалось открыть файл логов:\n{error}",
|
||||||
|
"dialog.autostart_fail": "Не удалось изменить автозапуск.\n\nПопробуйте запустить приложение от имени пользователя с правами на реестр.\n\nОшибка: {error}",
|
||||||
|
|
||||||
|
"update.available": "Доступна новая версия: {version}",
|
||||||
|
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
|
||||||
|
"update.downloading": "Скачивание...",
|
||||||
|
"update.replacing": "Замена файла...",
|
||||||
|
"update.restarting": "Перезапуск...",
|
||||||
|
"update.error": "Ошибка: {msg}",
|
||||||
|
"update.download_fail": "Не удалось скачать:\n{error}",
|
||||||
|
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
|
||||||
|
"update.move_fail": "Не удалось переместить файл:\n{error}",
|
||||||
|
|
||||||
|
"error.dc_config": "Ошибка конфигурации DC → IP.",
|
||||||
|
|
||||||
|
"diagnostics.port_busy": "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.",
|
||||||
|
"diagnostics.permission": "Не удалось запустить прокси:\nДоступ к адресу/порту запрещён (брандмауэр, антивирус или права доступа).\n\nИзмените порт на случайный в диапазоне 10000–50000 в настройках, проверьте брандмауэр/антивирус и перезапустите.",
|
||||||
|
"diagnostics.bad_address": "Не удалось запустить прокси:\nНекорректный или недоступный адрес для прослушивания.\n\nПроверьте решение по открывшейся в браузере ссылке.\nПроверьте host и порт в настройках прокси и перезапустите.",
|
||||||
|
|
||||||
|
"ipv6.warning": "На вашем компьютере включена поддержка подключения по IPv6.\n\nTelegram может пытаться подключаться через IPv6, что не поддерживается и может привести к ошибкам.\n\nЕсли прокси не работает или в логах присутствуют ошибки, связанные с попытками подключения по IPv6 - попробуйте отключить в настройках прокси Telegram попытку соединения по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 в системе.\n\nЭто предупреждение будет показано только один раз."
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import sys
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ui.i18n import detect_system_language
|
||||||
|
|
||||||
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||||
"port": 1443,
|
"port": 1443,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
@@ -18,15 +20,18 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"cfproxy": True,
|
"cfproxy": True,
|
||||||
|
"cfproxy_user_domain_enabled": False,
|
||||||
"cfproxy_user_domain": [],
|
"cfproxy_user_domain": [],
|
||||||
|
"cfproxy_worker_enabled": False,
|
||||||
"cfproxy_worker_domain": [],
|
"cfproxy_worker_domain": [],
|
||||||
"ws_keepalive_interval": 30
|
"force_test_dc": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_tray_config() -> Dict[str, Any]:
|
def default_tray_config() -> Dict[str, Any]:
|
||||||
cfg = dict(_TRAY_DEFAULTS_COMMON)
|
cfg = dict(_TRAY_DEFAULTS_COMMON)
|
||||||
cfg["secret"] = os.urandom(16).hex()
|
cfg["secret"] = os.urandom(16).hex()
|
||||||
|
cfg["language"] = detect_system_language().value
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
cfg["autostart"] = False
|
cfg["autostart"] = False
|
||||||
|
|||||||
+5
-26
@@ -5,29 +5,6 @@ import webbrowser
|
|||||||
|
|
||||||
from typing import Optional, Tuple, Callable
|
from typing import Optional, Tuple, Callable
|
||||||
|
|
||||||
|
|
||||||
MSG_PORT_BUSY = (
|
|
||||||
"Не удалось запустить прокси:\n"
|
|
||||||
"Порт уже используется другим приложением.\n\n"
|
|
||||||
"Закройте приложение, использующее этот порт, "
|
|
||||||
"или измените порт в настройках прокси и перезапустите."
|
|
||||||
)
|
|
||||||
|
|
||||||
MSG_PERMISSION = (
|
|
||||||
"Не удалось запустить прокси:\n"
|
|
||||||
"Доступ к адресу/порту запрещён "
|
|
||||||
"(брандмауэр, антивирус или права доступа).\n\n"
|
|
||||||
"Измените порт на случайный в диапазоне 10000–50000 в настройках, "
|
|
||||||
"проверьте брандмауэр/антивирус и перезапустите."
|
|
||||||
)
|
|
||||||
|
|
||||||
MSG_BAD_ADDRESS = (
|
|
||||||
"Не удалось запустить прокси:\n"
|
|
||||||
"Некорректный или недоступный адрес для прослушивания.\n\n"
|
|
||||||
"Проверьте решение по открывшейся в браузере ссылке.\n"
|
|
||||||
"Проверьте host и порт в настройках прокси и перезапустите."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
|
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
|
||||||
_WSA_EACCES = 10013
|
_WSA_EACCES = 10013
|
||||||
_WSA_EFAULT = 10014
|
_WSA_EFAULT = 10014
|
||||||
@@ -41,6 +18,8 @@ def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[C
|
|||||||
Returns None when the exception is not a recognizable bind failure,
|
Returns None when the exception is not a recognizable bind failure,
|
||||||
so callers can fall back to generic handling.
|
so callers can fall back to generic handling.
|
||||||
"""
|
"""
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
if not isinstance(exc, OSError):
|
if not isinstance(exc, OSError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -48,10 +27,10 @@ def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[C
|
|||||||
winerror = getattr(exc, "winerror", None)
|
winerror = getattr(exc, "winerror", None)
|
||||||
|
|
||||||
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
|
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
|
||||||
return MSG_PORT_BUSY, None
|
return t("diagnostics.port_busy"), None
|
||||||
if err == errno.EACCES or winerror == _WSA_EACCES:
|
if err == errno.EACCES or winerror == _WSA_EACCES:
|
||||||
return MSG_PERMISSION, None
|
return t("diagnostics.permission"), None
|
||||||
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
|
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
|
||||||
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
|
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
|
||||||
return MSG_BAD_ADDRESS, lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
|
return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
|
||||||
return None, None
|
return None, None
|
||||||
|
|||||||
+65
-24
@@ -41,7 +41,10 @@ def _exe_dir() -> Optional[Path]:
|
|||||||
return None
|
return None
|
||||||
if not base:
|
if not base:
|
||||||
return None
|
return None
|
||||||
p = Path(base).resolve()
|
try:
|
||||||
|
p = Path(base).resolve(strict=False)
|
||||||
|
except OSError:
|
||||||
|
p = Path(os.path.realpath(base))
|
||||||
return p.parent if p.is_file() else p
|
return p.parent if p.is_file() else p
|
||||||
|
|
||||||
|
|
||||||
@@ -180,18 +183,36 @@ def release_lock() -> None:
|
|||||||
|
|
||||||
# config
|
# config
|
||||||
|
|
||||||
|
def _apply_ui_language(cfg: dict) -> None:
|
||||||
|
from ui.i18n import set_language
|
||||||
|
|
||||||
|
set_language(cfg.get("language", DEFAULT_CONFIG["language"]))
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
cfg: Optional[dict] = None
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
if "cfproxy_user_domain_enabled" not in data:
|
||||||
|
data["cfproxy_user_domain_enabled"] = bool(
|
||||||
|
coerce_domain_list(data.get("cfproxy_user_domain"))
|
||||||
|
)
|
||||||
|
if "cfproxy_worker_enabled" not in data:
|
||||||
|
data["cfproxy_worker_enabled"] = bool(
|
||||||
|
coerce_domain_list(data.get("cfproxy_worker_domain"))
|
||||||
|
)
|
||||||
for k, v in DEFAULT_CONFIG.items():
|
for k, v in DEFAULT_CONFIG.items():
|
||||||
data.setdefault(k, v)
|
data.setdefault(k, v)
|
||||||
return data
|
cfg = data
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Failed to load config: %s", repr(exc))
|
log.warning("Failed to load config: %s", repr(exc))
|
||||||
return dict(DEFAULT_CONFIG)
|
if cfg is None:
|
||||||
|
cfg = dict(DEFAULT_CONFIG)
|
||||||
|
_apply_ui_language(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict) -> None:
|
def save_config(cfg: dict) -> None:
|
||||||
@@ -302,6 +323,17 @@ def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
|||||||
if diagnose_called:
|
if diagnose_called:
|
||||||
diagnose_called()
|
diagnose_called()
|
||||||
finally:
|
finally:
|
||||||
|
pending = [
|
||||||
|
task for task in asyncio.all_tasks(loop)
|
||||||
|
if not task.done()
|
||||||
|
]
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if pending:
|
||||||
|
loop.run_until_complete(asyncio.gather(
|
||||||
|
*pending, return_exceptions=True
|
||||||
|
))
|
||||||
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
loop.close()
|
loop.close()
|
||||||
_async_stop = None
|
_async_stop = None
|
||||||
|
|
||||||
@@ -322,9 +354,23 @@ def apply_proxy_config(cfg: dict) -> bool:
|
|||||||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
|
cfproxy_user_domains = coerce_domain_list(
|
||||||
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
||||||
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
|
)
|
||||||
|
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"])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -335,7 +381,8 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not apply_proxy_config(cfg):
|
if not apply_proxy_config(cfg):
|
||||||
on_error("Ошибка конфигурации DC → IP.")
|
from ui.i18n import t
|
||||||
|
on_error(t("error.dc_config"))
|
||||||
return
|
return
|
||||||
|
|
||||||
pc = proxy_config
|
pc = proxy_config
|
||||||
@@ -353,6 +400,9 @@ def stop_proxy() -> None:
|
|||||||
loop.call_soon_threadsafe(stop_ev.set)
|
loop.call_soon_threadsafe(stop_ev.set)
|
||||||
if _proxy_thread:
|
if _proxy_thread:
|
||||||
_proxy_thread.join(timeout=5)
|
_proxy_thread.join(timeout=5)
|
||||||
|
if _proxy_thread.is_alive():
|
||||||
|
log.warning("Proxy thread did not stop within timeout; "
|
||||||
|
"port may still be in use")
|
||||||
_proxy_thread = None
|
_proxy_thread = None
|
||||||
log.info("Proxy stopped")
|
log.info("Proxy stopped")
|
||||||
|
|
||||||
@@ -360,7 +410,7 @@ def stop_proxy() -> None:
|
|||||||
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||||
log.info("Restarting proxy...")
|
log.info("Restarting proxy...")
|
||||||
stop_proxy()
|
stop_proxy()
|
||||||
time.sleep(0.3)
|
time.sleep(1.0)
|
||||||
start_proxy(cfg, on_error)
|
start_proxy(cfg, on_error)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,19 +422,6 @@ def tg_proxy_url(cfg: dict) -> str:
|
|||||||
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||||
|
|
||||||
|
|
||||||
_IPV6_WARNING = (
|
|
||||||
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
|
|
||||||
"Telegram может пытаться подключаться через IPv6, "
|
|
||||||
"что не поддерживается и может привести к ошибкам.\n\n"
|
|
||||||
"Если прокси не работает или в логах присутствуют ошибки, "
|
|
||||||
"связанные с попытками подключения по IPv6 - "
|
|
||||||
"попробуйте отключить в настройках прокси Telegram попытку соединения "
|
|
||||||
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
|
|
||||||
"в системе.\n\n"
|
|
||||||
"Это предупреждение будет показано только один раз."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_ipv6() -> bool:
|
def _has_ipv6() -> bool:
|
||||||
try:
|
try:
|
||||||
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
||||||
@@ -407,8 +444,10 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
|||||||
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
||||||
return
|
return
|
||||||
IPV6_WARN_MARKER.touch()
|
IPV6_WARN_MARKER.touch()
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
|
target=lambda: show_info(t("ipv6.warning"), t("app.name")),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
@@ -437,9 +476,11 @@ def maybe_notify_update(
|
|||||||
return
|
return
|
||||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
ver = st.get("latest") or "?"
|
ver = st.get("latest") or "?"
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
if ask_open(
|
if ask_open(
|
||||||
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
|
t("update.ask_open", version=ver),
|
||||||
"TG WS Proxy — обновление",
|
t("app.update_title"),
|
||||||
):
|
):
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
+57
-13
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
|
Проверка новой версии через GitHub Releases API
|
||||||
|
|
||||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||||
@@ -18,6 +18,7 @@ from proxy.utils import build_github_opener
|
|||||||
|
|
||||||
REPO = "Flowseal/tg-ws-proxy"
|
REPO = "Flowseal/tg-ws-proxy"
|
||||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||||
|
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
|
||||||
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
||||||
|
|
||||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
||||||
@@ -223,19 +224,60 @@ def run_check(current_version: str) -> None:
|
|||||||
_state["html_url"] = RELEASES_PAGE_URL
|
_state["html_url"] = RELEASES_PAGE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_release_by_tag(
|
||||||
|
tag: str, timeout: float = 12.0,
|
||||||
|
) -> Tuple[Optional[dict], int]:
|
||||||
|
if not tag:
|
||||||
|
return None, 0
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "tg-ws-proxy-update-check",
|
||||||
|
}
|
||||||
|
req = Request(
|
||||||
|
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
|
||||||
|
headers=headers,
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||||
|
code = getattr(resp, "status", None) or resp.getcode()
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return json.loads(raw), int(code)
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code in [304, 404]:
|
||||||
|
return None, e.code
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_assets(data: Optional[dict]) -> list:
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
||||||
|
for a in (data.get("assets") or [])
|
||||||
|
if a.get("name") and a.get("browser_download_url")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_status() -> Dict[str, Any]:
|
def get_status() -> Dict[str, Any]:
|
||||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||||
return dict(_state)
|
return dict(_state)
|
||||||
|
|
||||||
|
|
||||||
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
||||||
assets = _state.get("assets") or []
|
new_assets = _state.get("assets") or []
|
||||||
if not assets:
|
if not new_assets:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try SHA256 match against release asset digests
|
target_name = None
|
||||||
|
|
||||||
|
# SHA256 match
|
||||||
try:
|
try:
|
||||||
import hashlib
|
import hashlib
|
||||||
|
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||||
|
if code == 200 and data:
|
||||||
|
cur_assets = _extract_assets(data)
|
||||||
|
if cur_assets:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with open(exe_path, "rb") as f:
|
with open(exe_path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
@@ -244,14 +286,16 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
|||||||
break
|
break
|
||||||
h.update(chunk)
|
h.update(chunk)
|
||||||
exe_sha = h.hexdigest().lower()
|
exe_sha = h.hexdigest().lower()
|
||||||
for a in assets:
|
for a in cur_assets:
|
||||||
d = (a.get("digest") or "").lower()
|
d = (a.get("digest") or "").lower()
|
||||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||||
return a["url"], a["name"]
|
target_name = a["name"]
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback
|
# Fallback
|
||||||
|
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
@@ -265,16 +309,16 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
|||||||
is_modern = True
|
is_modern = True
|
||||||
|
|
||||||
if is_arm64:
|
if is_arm64:
|
||||||
name = "TgWsProxy_windows_arm64.exe"
|
target_name = "TgWsProxy_windows_arm64.exe"
|
||||||
elif is_modern:
|
elif is_modern:
|
||||||
name = "TgWsProxy_windows.exe"
|
target_name = "TgWsProxy_windows.exe"
|
||||||
elif is_64:
|
elif is_64:
|
||||||
name = "TgWsProxy_windows_7_64bit.exe"
|
target_name = "TgWsProxy_windows_7_64bit.exe"
|
||||||
else:
|
else:
|
||||||
name = "TgWsProxy_windows_7_32bit.exe"
|
target_name = "TgWsProxy_windows_7_32bit.exe"
|
||||||
|
|
||||||
for a in assets:
|
for a in new_assets:
|
||||||
if a.get("name") == name:
|
if a.get("name") == target_name:
|
||||||
return a["url"], a["name"]
|
return a["url"], a["name"]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
+62
-49
@@ -56,6 +56,7 @@ from ui.ctk_theme import (
|
|||||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||||
)
|
)
|
||||||
|
from ui.i18n import set_language, t
|
||||||
|
|
||||||
_tray_icon: Optional[object] = None
|
_tray_icon: Optional[object] = None
|
||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
@@ -110,22 +111,23 @@ _IDYES = 6
|
|||||||
_IDNO = 7
|
_IDNO = 7
|
||||||
|
|
||||||
|
|
||||||
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
|
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||||
_u32.MessageBoxW(None, text, title, _MB_OK_ERR)
|
_u32.MessageBoxW(None, text, title or t("app.error_title"), _MB_OK_ERR)
|
||||||
|
|
||||||
|
|
||||||
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
|
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||||
_u32.MessageBoxW(None, text, title, _MB_OK_INFO)
|
_u32.MessageBoxW(None, text, title or t("app.name"), _MB_OK_INFO)
|
||||||
|
|
||||||
|
|
||||||
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
|
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||||
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
|
return _u32.MessageBoxW(None, text, title or t("app.name"), _MB_YESNO_Q) == _IDYES
|
||||||
|
|
||||||
|
|
||||||
def update_ctk_form(
|
def update_ctk_form(
|
||||||
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None,
|
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
|
||||||
release_url: Optional[str] = None,
|
release_url: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
title = title or t("app.name")
|
||||||
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
||||||
if result == _IDYES:
|
if result == _IDYES:
|
||||||
@@ -194,19 +196,19 @@ def update_ctk_form(
|
|||||||
|
|
||||||
if IS_FROZEN:
|
if IS_FROZEN:
|
||||||
btn_upd = ctk.CTkButton(
|
btn_upd = ctk.CTkButton(
|
||||||
row, text="Обновить", width=88, height=34,
|
row, text=t("button.update"), width=88, height=34,
|
||||||
font=(theme.ui_font_family, 13), command=_on_update,
|
font=(theme.ui_font_family, 13), command=_on_update,
|
||||||
)
|
)
|
||||||
btn_upd.pack(side="left", padx=(0, 6))
|
btn_upd.pack(side="left", padx=(0, 6))
|
||||||
btns.append(btn_upd)
|
btns.append(btn_upd)
|
||||||
btn_pg = ctk.CTkButton(
|
btn_pg = ctk.CTkButton(
|
||||||
row, text="Страница", width=88, height=34,
|
row, text=t("button.page"), width=88, height=34,
|
||||||
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
|
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
|
||||||
)
|
)
|
||||||
btn_pg.pack(side="left", padx=(0, 6))
|
btn_pg.pack(side="left", padx=(0, 6))
|
||||||
btns.append(btn_pg)
|
btns.append(btn_pg)
|
||||||
btn_cl = ctk.CTkButton(
|
btn_cl = ctk.CTkButton(
|
||||||
row, text="Закрыть", width=88, height=34,
|
row, text=t("button.close"), width=88, height=34,
|
||||||
font=(theme.ui_font_family, 13),
|
font=(theme.ui_font_family, 13),
|
||||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||||
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
|
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
|
||||||
@@ -231,11 +233,11 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
def _err(msg: str) -> None:
|
def _err(msg: str) -> None:
|
||||||
log.error("Update error: %s", msg)
|
log.error("Update error: %s", msg)
|
||||||
if set_status:
|
if set_status:
|
||||||
set_status(f"Ошибка: {msg}")
|
set_status(f"{t('update.error', msg=msg)}")
|
||||||
else:
|
else:
|
||||||
_show_error(msg)
|
_show_error(msg)
|
||||||
|
|
||||||
_step("Скачивание...")
|
_step(t("update.downloading"))
|
||||||
cur_exe = Path(sys.executable)
|
cur_exe = Path(sys.executable)
|
||||||
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
|
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
|
||||||
tmp_path = None
|
tmp_path = None
|
||||||
@@ -253,7 +255,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
break
|
break
|
||||||
_fout.write(_chunk)
|
_fout.write(_chunk)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_err(f"Не удалось скачать:\n{exc}")
|
_err(t("update.download_fail", error=exc))
|
||||||
if tmp_path:
|
if tmp_path:
|
||||||
try:
|
try:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
@@ -261,13 +263,13 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
_step("Замена файла...")
|
_step(t("update.replacing"))
|
||||||
try:
|
try:
|
||||||
if old_exe.exists():
|
if old_exe.exists():
|
||||||
old_exe.unlink()
|
old_exe.unlink()
|
||||||
cur_exe.rename(old_exe)
|
cur_exe.rename(old_exe)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_err(f"Не удалось переименовать файл:\n{exc}")
|
_err(t("update.rename_fail", error=exc))
|
||||||
try:
|
try:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -277,7 +279,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
try:
|
try:
|
||||||
tmp_path.rename(cur_exe)
|
tmp_path.rename(cur_exe)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_err(f"Не удалось переместить файл:\n{exc}")
|
_err(t("update.move_fail", error=exc))
|
||||||
try:
|
try:
|
||||||
old_exe.rename(cur_exe)
|
old_exe.rename(cur_exe)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -288,7 +290,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
_step("Перезапуск...")
|
_step(t("update.restarting"))
|
||||||
_release_win_mutex()
|
_release_win_mutex()
|
||||||
stop_proxy()
|
stop_proxy()
|
||||||
|
|
||||||
@@ -333,9 +335,9 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
|||||||
return
|
return
|
||||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
ver = st.get("latest") or "?"
|
ver = st.get("latest") or "?"
|
||||||
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
|
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||||
choice = update_ctk_form(
|
choice = update_ctk_form(
|
||||||
f"Доступна новая версия: {ver}",
|
t("update.available", version=ver),
|
||||||
download_url=asset[0] if asset else None,
|
download_url=asset[0] if asset else None,
|
||||||
release_url=url,
|
release_url=url,
|
||||||
)
|
)
|
||||||
@@ -382,9 +384,7 @@ def set_autostart_enabled(enabled: bool) -> None:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
log.error("Failed to update autostart: %s", exc)
|
log.error("Failed to update autostart: %s", exc)
|
||||||
_show_error(
|
_show_error(
|
||||||
"Не удалось изменить автозапуск.\n\n"
|
t("dialog.autostart_fail", error=exc)
|
||||||
"Попробуйте запустить приложение от имени пользователя "
|
|
||||||
f"с правами на реестр.\n\nОшибка: {exc}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -400,34 +400,30 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
|
|||||||
log.info("Browser open failed, copying to clipboard")
|
log.info("Browser open failed, copying to clipboard")
|
||||||
if pyperclip is None:
|
if pyperclip is None:
|
||||||
_show_error(
|
_show_error(
|
||||||
"Не удалось открыть Telegram автоматически.\n\n"
|
t("dialog.open_tg_fail_manual", url=url)
|
||||||
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
pyperclip.copy(url)
|
pyperclip.copy(url)
|
||||||
_show_info(
|
_show_info(
|
||||||
"Не удалось открыть Telegram автоматически.\n\n"
|
t("dialog.open_tg_fail_clipboard", url=url)
|
||||||
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Clipboard copy failed: %s", exc)
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
def _on_copy_link(icon=None, item=None) -> None:
|
def _on_copy_link(icon=None, item=None) -> None:
|
||||||
url = tg_proxy_url(_config)
|
url = tg_proxy_url(_config)
|
||||||
log.info("Copying link: %s", url)
|
log.info("Copying link: %s", url)
|
||||||
if pyperclip is None:
|
if pyperclip is None:
|
||||||
_show_error(
|
_show_error(t("dialog.pyperclip_missing"))
|
||||||
"Установите пакет pyperclip для копирования в буфер обмена."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
pyperclip.copy(url)
|
pyperclip.copy(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Clipboard copy failed: %s", exc)
|
log.error("Clipboard copy failed: %s", exc)
|
||||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
_show_error(t("dialog.copy_fail", error=exc))
|
||||||
|
|
||||||
|
|
||||||
def _on_restart(icon=None, item=None) -> None:
|
def _on_restart(icon=None, item=None) -> None:
|
||||||
@@ -447,9 +443,9 @@ def _on_open_logs(icon=None, item=None) -> None:
|
|||||||
os.startfile(str(LOG_FILE))
|
os.startfile(str(LOG_FILE))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Failed to open log file: %s", exc)
|
log.error("Failed to open log file: %s", exc)
|
||||||
_show_error(f"Не удалось открыть файл логов:\n{exc}")
|
_show_error(t("dialog.log_open_fail", error=exc))
|
||||||
else:
|
else:
|
||||||
_show_info("Файл логов ещё не создан.")
|
_show_info(t("dialog.log_not_found"))
|
||||||
|
|
||||||
|
|
||||||
def _on_exit(icon=None, item=None) -> None:
|
def _on_exit(icon=None, item=None) -> None:
|
||||||
@@ -469,7 +465,7 @@ def _on_exit(icon=None, item=None) -> None:
|
|||||||
|
|
||||||
def _edit_config_dialog() -> None:
|
def _edit_config_dialog() -> None:
|
||||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
_show_error("customtkinter не установлен.")
|
_show_error(t("dialog.ctk_missing"))
|
||||||
return
|
return
|
||||||
|
|
||||||
cfg = dict(_config)
|
cfg = dict(_config)
|
||||||
@@ -484,45 +480,62 @@ def _edit_config_dialog() -> None:
|
|||||||
h += 100
|
h += 100
|
||||||
|
|
||||||
root = create_ctk_toplevel(
|
root = create_ctk_toplevel(
|
||||||
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
|
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||||
)
|
)
|
||||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||||
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
||||||
|
|
||||||
|
def _refresh_tray_menu() -> None:
|
||||||
|
if _tray_icon is not None:
|
||||||
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
|
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
|
||||||
|
|
||||||
widgets = install_tray_config_form(
|
widgets = install_tray_config_form(
|
||||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||||
show_autostart=_supports_autostart(),
|
show_autostart=_supports_autostart(),
|
||||||
autostart_value=cfg.get("autostart", False),
|
autostart_value=cfg.get("autostart", False),
|
||||||
|
on_language_change=_refresh_tray_menu,
|
||||||
)
|
)
|
||||||
|
|
||||||
_original_appearance = ctk.get_appearance_mode()
|
_original_appearance = ctk.get_appearance_mode()
|
||||||
|
|
||||||
|
def _restore_ui_locale() -> None:
|
||||||
|
set_language(_original_language)
|
||||||
|
_refresh_tray_menu()
|
||||||
|
|
||||||
def _finish() -> None:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
def _cancel() -> None:
|
def _cancel() -> None:
|
||||||
ctk.set_appearance_mode(_original_appearance)
|
ctk.set_appearance_mode(_original_appearance)
|
||||||
|
_restore_ui_locale()
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
def on_save() -> None:
|
def on_save() -> None:
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
||||||
if isinstance(merged, str):
|
if isinstance(merged, str):
|
||||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
_ui_only_keys = {"appearance", "autostart", "check_updates"}
|
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
|
||||||
|
|
||||||
|
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
|
||||||
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
|
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
|
||||||
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
|
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
|
||||||
|
|
||||||
if not config_changed:
|
if not config_changed:
|
||||||
|
_restore_ui_locale()
|
||||||
_finish()
|
_finish()
|
||||||
return
|
return
|
||||||
|
|
||||||
save_config(merged)
|
save_config(merged)
|
||||||
_config.update(merged)
|
_config.update(merged)
|
||||||
|
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
|
||||||
log.info("Config saved: %s", merged)
|
log.info("Config saved: %s", merged)
|
||||||
if _supports_autostart():
|
if _supports_autostart():
|
||||||
set_autostart_enabled(bool(merged.get("autostart", False)))
|
set_autostart_enabled(bool(merged.get("autostart", False)))
|
||||||
@@ -533,8 +546,8 @@ def _edit_config_dialog() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
do_restart = messagebox.askyesno(
|
do_restart = messagebox.askyesno(
|
||||||
"Перезапустить?",
|
t("dialog.restart_title"),
|
||||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
t("dialog.restart_body"),
|
||||||
parent=root,
|
parent=root,
|
||||||
)
|
)
|
||||||
_finish()
|
_finish()
|
||||||
@@ -565,7 +578,7 @@ def _show_first_run() -> None:
|
|||||||
theme = ctk_theme_for_platform()
|
theme = ctk_theme_for_platform()
|
||||||
w, h = FIRST_RUN_SIZE
|
w, h = FIRST_RUN_SIZE
|
||||||
root = create_ctk_toplevel(
|
root = create_ctk_toplevel(
|
||||||
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
|
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -590,14 +603,14 @@ def _build_menu():
|
|||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
return pystray.Menu(
|
||||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Перезапустить прокси", _on_restart),
|
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||||
pystray.MenuItem("Настройки...", _on_edit_config),
|
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||||
pystray.MenuItem("Открыть логи", _on_open_logs),
|
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem("Выход", _on_exit),
|
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -628,7 +641,7 @@ def run_tray() -> None:
|
|||||||
_show_first_run()
|
_show_first_run()
|
||||||
check_ipv6_warning(_show_info)
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
|
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
|
||||||
log.info("Tray icon running")
|
log.info("Tray icon running")
|
||||||
_tray_icon.run()
|
_tray_icon.run()
|
||||||
|
|
||||||
@@ -638,7 +651,7 @@ def run_tray() -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
|
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
|
||||||
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
|
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||||
return
|
return
|
||||||
|
|
||||||
if IS_FROZEN:
|
if IS_FROZEN:
|
||||||
|
|||||||
Reference in New Issue
Block a user