mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-09-06 18:17:03 +00:00
Compare commits
37 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 |
@@ -0,0 +1,2 @@
|
|||||||
|
trusted_users:
|
||||||
|
- Flowseal
|
||||||
@@ -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,6 +303,12 @@ 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: 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
|
- name: Create macOS icon
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -287,6 +324,7 @@ jobs:
|
|||||||
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
||||||
test -n "$ICON_FILE"
|
test -n "$ICON_FILE"
|
||||||
test -f "dist/TG WS Proxy.app/Contents/Resources/$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
|
||||||
@@ -345,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
|
||||||
@@ -511,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:
|
||||||
@@ -525,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
|
||||||
|
|||||||
+15
-1
@@ -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).
|
||||||
+18
-11
@@ -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,15 +46,16 @@
|
|||||||
## Навигация
|
## Навигация
|
||||||
|
|
||||||
- **🚀 Быстрый старт**
|
- **🚀 Быстрый старт**
|
||||||
- **[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)
|
||||||
|
- [Установка из исходников](./RU/BuildFromSource.md)
|
||||||
- [Руководство для контрибьюторов](./CONTRIBUTING.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
|
||||||
@@ -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 |
@@ -188,6 +188,8 @@ def _edit_config_dialog() -> None:
|
|||||||
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
|
||||||
|
|
||||||
_ui_only_keys = {"appearance", "check_updates", "language"}
|
_ui_only_keys = {"appearance", "check_updates", "language"}
|
||||||
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
|
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)
|
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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')
|
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||||
|
|
||||||
@@ -28,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/'), (_i18n_path, 'ui/i18n')] + 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',
|
||||||
|
|||||||
+13
-9
@@ -1,24 +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')
|
_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=[(_i18n_path, 'ui/i18n')],
|
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',
|
||||||
@@ -30,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 = [
|
||||||
@@ -93,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, 8, 0, 0),
|
filevers=(1, 10, 0, 0),
|
||||||
prodvers=(1, 8, 0, 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.8.0.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.8.0.0'),
|
StringStruct(u'ProductVersion', u'1.10.0.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
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')
|
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@ 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/'), (_i18n_path, 'ui/i18n')],
|
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.8.0"
|
__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"]
|
||||||
+32
-27
@@ -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,38 +181,43 @@ 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,
|
'dc': str(dc),
|
||||||
'dc': str(dc),
|
})
|
||||||
})
|
path = f'/apiws?{query}'
|
||||||
path = f'/apiws?{query}'
|
|
||||||
|
|
||||||
|
ws = None
|
||||||
|
for worker_domain in cf_worker_pool.available_domains(worker_domains):
|
||||||
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||||
label, dc, media_tag, worker_domain, fallback_dst)
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
timeout=10.0, path=path)
|
timeout=10.0, path=path)
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
cf_worker_pool.report_failure(worker_domain, exc)
|
||||||
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||||
label, dc, media_tag, worker_domain, repr(exc))
|
label, dc, media_tag, worker_domain, repr(exc))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stats.connections_cfproxy += 1
|
if ws is None:
|
||||||
await ws.send(relay_init)
|
return False
|
||||||
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
|
||||||
dc=dc, is_media=is_media,
|
stats.connections_cfproxy += 1
|
||||||
splitter=splitter)
|
await ws.send(relay_init)
|
||||||
return True
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
return False
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
|
|||||||
+3
-2
@@ -72,6 +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
|
||||||
|
force_test_dc: bool = False
|
||||||
|
|
||||||
|
|
||||||
proxy_config = ProxyConfig()
|
proxy_config = ProxyConfig()
|
||||||
@@ -208,11 +209,11 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
|||||||
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):
|
||||||
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
||||||
err.entry = entry
|
err.entry = entry
|
||||||
err.kind = "invalid"
|
err.kind = "invalid"
|
||||||
raise err
|
raise err from None
|
||||||
dc_redirects[dc_n] = ip_s
|
dc_redirects[dc_n] = ip_s
|
||||||
return dc_redirects
|
return dc_redirects
|
||||||
|
|||||||
+193
-56
@@ -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,14 +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.fronting_until: float = 0.0
|
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()
|
||||||
@@ -41,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._schedule_refill(key, target_ip, domains)
|
self.report_success(dc, is_media)
|
||||||
|
if allow_refill:
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
stats.pool_misses += 1
|
stats.pool_misses += 1
|
||||||
self._schedule_refill(key, target_ip, domains)
|
if allow_refill:
|
||||||
|
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:
|
||||||
@@ -61,27 +78,98 @@ 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, time.monotonic() < self.fronting_until))
|
self._connect_one(target_ip, domains))
|
||||||
for _ in range(needed)]
|
for _ in range(needed)]
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
try:
|
try:
|
||||||
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, fronting_active) -> Optional[RawWebSocket]:
|
if key in self._rotating:
|
||||||
|
return
|
||||||
|
self._rotating[key] = asyncio.create_task(
|
||||||
|
self._rotate(key, target_ip, domains))
|
||||||
|
|
||||||
|
async def _rotate(self, key, target_ip, domains):
|
||||||
|
dc, is_media = key
|
||||||
|
try:
|
||||||
|
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:
|
for domain in domains:
|
||||||
|
if self.try_fronting_first:
|
||||||
|
ws = await self._connect_fronted(target_ip, domain)
|
||||||
|
if ws:
|
||||||
|
return ws
|
||||||
try:
|
try:
|
||||||
return await RawWebSocket.connect(
|
ws = await RawWebSocket.connect(
|
||||||
target_ip, domain, timeout=8, sni="sprinthost.ru" if fronting_active else None)
|
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
|
||||||
@@ -90,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:
|
||||||
@@ -107,86 +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.fronting_until = 0.0
|
self._rotating.clear()
|
||||||
|
self._refill_failures.clear()
|
||||||
|
self._refill_after.clear()
|
||||||
|
self.try_fronting_first = False
|
||||||
|
|
||||||
|
|
||||||
class _CfWorkerPool:
|
class _CfWorkerPool:
|
||||||
WS_POOL_MAX_AGE = 100.0
|
WS_POOL_MAX_AGE = 100.0
|
||||||
|
PER_DC_LIMIT = 1
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[Tuple[int, str], deque] = {}
|
self._idle: Dict[int, deque] = {}
|
||||||
self._refilling: Set[Tuple[int, str]] = set()
|
self._refilling: Set[int] = set()
|
||||||
|
self._exhausted_until: Dict[str, float] = {}
|
||||||
|
|
||||||
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
|
async def get(self, dc: int, fallback_dst: str,
|
||||||
|
worker_domains: List[str]
|
||||||
|
) -> Optional[Tuple[RawWebSocket, str]]:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
key = (dc, worker_domain)
|
|
||||||
|
|
||||||
bucket = self._idle.get(key)
|
bucket = self._idle.get(dc)
|
||||||
if bucket is None:
|
if bucket is None:
|
||||||
bucket = deque()
|
bucket = deque()
|
||||||
self._idle[key] = bucket
|
self._idle[dc] = bucket
|
||||||
while bucket:
|
while bucket:
|
||||||
ws, created = bucket.popleft()
|
ws, created, worker_domain = bucket.popleft()
|
||||||
age = now - created
|
age = now - created
|
||||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
or ws.writer.transport.is_closing()):
|
or ws.writer.transport.is_closing()):
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
continue
|
continue
|
||||||
stats.cf_pool_hits += 1
|
stats.cf_pool_hits += 1
|
||||||
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
|
log.debug(
|
||||||
dc, age, len(bucket))
|
"CF worker pool hit DC%d via %s (age=%.1fs, left=%d)",
|
||||||
self._schedule_refill(key, fallback_dst)
|
dc, worker_domain, age, len(bucket))
|
||||||
return ws
|
self._schedule_refill(dc, fallback_dst, worker_domains)
|
||||||
|
return ws, worker_domain
|
||||||
|
|
||||||
stats.cf_pool_misses += 1
|
stats.cf_pool_misses += 1
|
||||||
self._schedule_refill(key, fallback_dst)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _schedule_refill(self, key, fallback_dst):
|
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
||||||
if key in self._refilling:
|
if dc in self._refilling:
|
||||||
return
|
return
|
||||||
self._refilling.add(key)
|
self._refilling.add(dc)
|
||||||
asyncio.create_task(self._refill(key, fallback_dst))
|
asyncio.create_task(self._refill(
|
||||||
|
dc, fallback_dst, list(worker_domains)))
|
||||||
|
|
||||||
async def _refill(self, key, fallback_dst):
|
async def _refill(self, dc, fallback_dst, worker_domains):
|
||||||
dc, worker_domain = key
|
|
||||||
try:
|
try:
|
||||||
bucket = self._idle.setdefault(key, deque())
|
bucket = self._idle.setdefault(dc, deque())
|
||||||
needed = proxy_config.pool_size - len(bucket)
|
target_size = min(proxy_config.pool_size, self.PER_DC_LIMIT)
|
||||||
|
needed = target_size - len(bucket)
|
||||||
if needed <= 0:
|
if needed <= 0:
|
||||||
return
|
return
|
||||||
tasks = [asyncio.create_task(
|
|
||||||
self._connect_one(worker_domain, fallback_dst, dc))
|
for _ in range(needed):
|
||||||
for _ in range(needed)]
|
connected = await self._connect_one(
|
||||||
for t in tasks:
|
worker_domains, fallback_dst, dc)
|
||||||
try:
|
if connected is None:
|
||||||
ws = await t
|
break
|
||||||
if ws:
|
ws, worker_domain = connected
|
||||||
bucket.append((ws, time.monotonic()))
|
bucket.append((ws, time.monotonic(), worker_domain))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.debug("CF worker pool refilled DC%d: %d ready",
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
dc, len(bucket))
|
dc, len(bucket))
|
||||||
finally:
|
finally:
|
||||||
self._refilling.discard(key)
|
self._refilling.discard(dc)
|
||||||
|
|
||||||
@staticmethod
|
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
||||||
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
|
|
||||||
query = urlencode({
|
query = urlencode({
|
||||||
'dst': fallback_dst,
|
'dst': fallback_dst,
|
||||||
'dc': str(dc),
|
'dc': str(dc),
|
||||||
})
|
})
|
||||||
path = f'/apiws?{query}'
|
path = f'/apiws?{query}'
|
||||||
try:
|
for worker_domain in self.available_domains(worker_domains):
|
||||||
return await RawWebSocket.connect(
|
try:
|
||||||
worker_domain, worker_domain, timeout=8, path=path)
|
ws = await RawWebSocket.connect(
|
||||||
except Exception:
|
worker_domain, worker_domain, timeout=8, path=path)
|
||||||
return None
|
return ws, worker_domain
|
||||||
|
except Exception as exc:
|
||||||
|
self.report_failure(worker_domain, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
def available_domains(self, worker_domains: List[str]) -> List[str]:
|
||||||
async def _quiet_close(ws):
|
now = time.time()
|
||||||
|
domains = []
|
||||||
|
for domain in worker_domains:
|
||||||
|
if domain in domains:
|
||||||
|
continue
|
||||||
|
exhausted_until = self._exhausted_until.get(domain, 0)
|
||||||
|
if exhausted_until > now:
|
||||||
|
continue
|
||||||
|
if exhausted_until:
|
||||||
|
self._exhausted_until.pop(domain, None)
|
||||||
|
domains.append(domain)
|
||||||
|
random.shuffle(domains)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def report_failure(self, worker_domain: str, exc: Exception) -> None:
|
||||||
|
return # TODO: check status code after daily limit reached
|
||||||
|
if not isinstance(exc, WsHandshakeError) or exc.status_code != 429:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if self._exhausted_until.get(worker_domain, 0) > now:
|
||||||
|
return
|
||||||
|
exhausted_until = now + (86400 - (now % 86400))
|
||||||
|
self._exhausted_until[worker_domain] = exhausted_until
|
||||||
|
log.warning(
|
||||||
|
"CF worker %s reached its request limit, disabled for %d seconds", worker_domain, int(exhausted_until - now))
|
||||||
|
|
||||||
|
async def _quiet_close(self, ws):
|
||||||
try:
|
try:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -201,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()
|
||||||
|
|||||||
+24
-7
@@ -67,18 +67,22 @@ 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,
|
||||||
@@ -164,7 +168,7 @@ class RawWebSocket:
|
|||||||
|
|
||||||
async def recv(self) -> Optional[bytes]:
|
async def recv(self) -> Optional[bytes]:
|
||||||
while not self._closed:
|
while not self._closed:
|
||||||
opcode, payload = await self._read_frame()
|
opcode, payload, fin = await self._read_frame()
|
||||||
|
|
||||||
if opcode == self.OP_CLOSE:
|
if opcode == self.OP_CLOSE:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
@@ -192,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):
|
||||||
return payload
|
if fin and not self._frag:
|
||||||
|
return payload
|
||||||
|
self._frag.extend(payload)
|
||||||
|
if len(self._frag) > self.MAX_MESSAGE_LEN:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"WS message too large: {len(self._frag)} bytes")
|
||||||
|
if not fin:
|
||||||
|
continue
|
||||||
|
message = bytes(self._frag)
|
||||||
|
self._frag.clear()
|
||||||
|
return message
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -251,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
|
||||||
+55
-70
@@ -33,14 +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
|
||||||
FRONTING_COOLDOWN = 1800.0
|
|
||||||
LISTENER_CHECK_INTERVAL = 5.0
|
LISTENER_CHECK_INTERVAL = 5.0
|
||||||
LISTENER_RESTART_DELAY = 1.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] = {}
|
||||||
fronting_until: float = 0.0
|
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]]:
|
||||||
@@ -248,8 +248,6 @@ def _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init):
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_client(reader, writer, secret: bytes):
|
async def _handle_client(reader, writer, secret: bytes):
|
||||||
global fronting_until
|
|
||||||
|
|
||||||
stats.connections_total += 1
|
stats.connections_total += 1
|
||||||
stats.connections_active += 1
|
stats.connections_active += 1
|
||||||
peer = writer.get_extra_info('peername')
|
peer = writer.get_extra_info('peername')
|
||||||
@@ -278,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:
|
||||||
@@ -293,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)
|
||||||
@@ -311,56 +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
|
|
||||||
fronting_active = now < fronting_until
|
|
||||||
|
|
||||||
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
|
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)
|
||||||
elif fronting_active:
|
|
||||||
# TODO: Move fronting logic into bridge.py where other fallbacks are handled
|
|
||||||
log.info("[%s] DC%d%s -> fronting / Host %s",
|
|
||||||
label, dc, media_tag, domains[0])
|
|
||||||
try:
|
|
||||||
ws = await RawWebSocket.connect(target, domains[0],
|
|
||||||
timeout=10.0,
|
|
||||||
sni="sprinthost.ru")
|
|
||||||
except Exception as exc:
|
|
||||||
stats.ws_errors += 1
|
|
||||||
log.warning("[%s] DC%d%s fronting failed: %s",
|
|
||||||
label, dc, media_tag, repr(exc))
|
|
||||||
if ws:
|
|
||||||
stats.connections_fronting += 1
|
|
||||||
fronting_until = now + FRONTING_COOLDOWN
|
|
||||||
ws_pool.fronting_until = fronting_until
|
|
||||||
else:
|
|
||||||
fronting_until = 0.0
|
|
||||||
ws_pool.fronting_until = 0.0
|
|
||||||
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:
|
||||||
@@ -381,35 +376,20 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
ws_timed_out = True
|
ws_timed_out = True
|
||||||
log.warning("[%s] DC%d%s WS connect timed out via %s",
|
log.warning("[%s] DC%d%s WS connect timed out via %s",
|
||||||
label, dc, media_tag, domain)
|
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
|
||||||
log.warning("[%s] DC%d%s WS connect failed: %s",
|
log.warning("[%s] DC%d%s WS connect failed: %s",
|
||||||
label, dc, media_tag, repr(exc))
|
label, dc, media_tag, repr(exc))
|
||||||
|
|
||||||
# Fronting fallback if WS timed out
|
|
||||||
# TODO: Move fronting logic into bridge.py where other fallbacks are handled
|
|
||||||
# and don't forget about WsPool fronting fallback
|
|
||||||
if ws is None and ws_timed_out and not fronting_active:
|
|
||||||
log.info("[%s] DC%d%s -> fronting fallback (Host %s)",
|
|
||||||
label, dc, media_tag, domains[0])
|
|
||||||
try:
|
|
||||||
ws = await RawWebSocket.connect(target, domains[0],
|
|
||||||
timeout=10.0,
|
|
||||||
sni="sprinthost.ru")
|
|
||||||
except Exception as exc:
|
|
||||||
stats.ws_errors += 1
|
|
||||||
log.warning("[%s] DC%d%s fronting failed: %s",
|
|
||||||
label, dc, media_tag, repr(exc))
|
|
||||||
if ws:
|
|
||||||
fronting_until = now + FRONTING_COOLDOWN
|
|
||||||
ws_pool.fronting_until = now + FRONTING_COOLDOWN
|
|
||||||
stats.connections_fronting += 1
|
|
||||||
log.info("[%s] DC%d%s fronting OK for %ds",
|
|
||||||
label, dc, media_tag, int(FRONTING_COOLDOWN))
|
|
||||||
|
|
||||||
# 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)",
|
||||||
@@ -418,8 +398,6 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
||||||
else:
|
else:
|
||||||
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
||||||
# TODO: may be don't try regular WS connection and do fallback instanstly
|
|
||||||
# instead of waiting for WS_FAIL_TIMEOUT and then fallback
|
|
||||||
log.info("[%s] DC%d%s WS cooldown for %ds",
|
log.info("[%s] DC%d%s WS cooldown for %ds",
|
||||||
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
|
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
|
||||||
|
|
||||||
@@ -430,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",
|
||||||
@@ -438,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
|
||||||
@@ -484,22 +464,21 @@ _client_tasks: Set[asyncio.Task] = set()
|
|||||||
|
|
||||||
|
|
||||||
async def _run(stop_event: Optional[asyncio.Event] = None):
|
async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||||
global _server_instance, _server_stop_event, fronting_until
|
global _server_instance, _server_stop_event
|
||||||
_server_stop_event = stop_event
|
_server_stop_event = stop_event
|
||||||
|
|
||||||
ws_pool.reset()
|
ws_pool.reset()
|
||||||
cf_worker_pool.reset()
|
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()
|
||||||
fronting_until = 0.0
|
|
||||||
|
|
||||||
if proxy_config.fallback_cfproxy:
|
user_cf_domains = proxy_config.cfproxy_user_domains
|
||||||
user = proxy_config.cfproxy_user_domains
|
if user_cf_domains:
|
||||||
if user:
|
balancer.update_domains_list(user_cf_domains)
|
||||||
balancer.update_domains_list(user)
|
else:
|
||||||
else:
|
start_cfproxy_domain_refresh()
|
||||||
start_cfproxy_domain_refresh()
|
|
||||||
|
|
||||||
secret_bytes = bytes.fromhex(proxy_config.secret)
|
secret_bytes = bytes.fromhex(proxy_config.secret)
|
||||||
|
|
||||||
@@ -540,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)",
|
||||||
@@ -691,6 +670,11 @@ 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)')
|
||||||
@@ -730,6 +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.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}")
|
||||||
|
|||||||
+56
-23
@@ -27,8 +27,12 @@ from ui.i18n import (
|
|||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
|
||||||
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
def _get_doc_url(doc_name: str) -> str:
|
||||||
|
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',
|
||||||
@@ -248,11 +252,11 @@ def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -351,7 +355,9 @@ 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
|
language_var: Optional[Any] = None
|
||||||
@@ -400,7 +406,7 @@ def install_tray_config_form(
|
|||||||
text_color="#ffffff", border_width=0,
|
text_color="#ffffff", border_width=0,
|
||||||
command=lambda: (
|
command=lambda: (
|
||||||
header.winfo_toplevel().iconify(),
|
header.winfo_toplevel().iconify(),
|
||||||
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
|
webbrowser.open(_get_doc_url("Funding")),
|
||||||
),
|
),
|
||||||
).pack(side="right", padx=(0, 6))
|
).pack(side="right", padx=(0, 6))
|
||||||
|
|
||||||
@@ -591,7 +597,9 @@ 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(
|
||||||
|
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 = _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, t("tip.cfproxy_user_domain_cb"))
|
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||||
@@ -601,7 +609,7 @@ 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(_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))
|
||||||
@@ -615,8 +623,6 @@ def install_tray_config_form(
|
|||||||
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()
|
||||||
@@ -626,16 +632,25 @@ def install_tray_config_form(
|
|||||||
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, t("label.cfworker_domains"), 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,
|
||||||
@@ -649,13 +664,16 @@ 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=t("button.test_loading"), state="disabled")
|
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
@@ -682,20 +700,27 @@ 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=t("button.test"), 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
|
||||||
|
|
||||||
|
def _sync_cfworker_entry(*_):
|
||||||
|
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
|
||||||
|
cf_worker_entry.configure(state=state)
|
||||||
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
|
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
|
||||||
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||||
_sync_cfworker_test_button()
|
_sync_cfworker_entry()
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
|
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
|
||||||
|
|
||||||
@@ -780,7 +805,9 @@ 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,
|
language_var=language_var,
|
||||||
@@ -872,8 +899,14 @@ def validate_config_form(
|
|||||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||||
if widgets.cfproxy_var is not None:
|
if widgets.cfproxy_var is not None:
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
|
if widgets.cfproxy_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:
|
||||||
|
|||||||
+4
-1
@@ -25,7 +25,10 @@ class LocaleEnum(str, Enum):
|
|||||||
return _DEFAULT_LOCALE
|
return _DEFAULT_LOCALE
|
||||||
|
|
||||||
|
|
||||||
_LOCALES_DIR = Path(__file__).resolve().parent
|
_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
|
_DEFAULT_LOCALE = LocaleEnum.english
|
||||||
|
|
||||||
_translations: Dict[str, str] = {}
|
_translations: Dict[str, str] = {}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ _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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-4
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -193,6 +196,14 @@ def load_config() -> dict:
|
|||||||
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)
|
||||||
cfg = data
|
cfg = data
|
||||||
@@ -312,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
|
||||||
|
|
||||||
@@ -332,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -522,8 +522,10 @@ def _edit_config_dialog() -> None:
|
|||||||
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
|
||||||
|
|
||||||
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
|
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
|
||||||
config_changed = any(merged.get(k) != _config.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) != _config.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:
|
||||||
|
|||||||
Reference in New Issue
Block a user