Compare commits

...
33 Commits
Author SHA1 Message Date
Flowseal 307a384ab7 Optional build signing 2026-10-11 19:44:57 +03:00
Flowseal 241d76d7b9 Bump version 2026-10-11 19:23:07 +03:00
Flowseal 7460091c19 SignPath CI integration 2026-10-11 19:20:53 +03:00
Flowseal 9deed3707a Update adjusts 2026-10-11 18:57:56 +03:00
Flowseal c52714f264 Manual update check 2026-10-11 18:01:21 +03:00
Flowseal 03be9baad1 Updates refactoring 2026-10-11 15:55:00 +03:00
holdingq0 c317406b52 Пул CF worker: закрытие при остановке, OPTI, сборка, доки (#1411) 2026-10-10 22:36:55 +03:00
Flowseal bf07b42f88 #1223 fixes, timeout for downloads 2026-10-10 22:08:57 +03:00
Nikita Kolesnikov a6177fec87 feat: автообновление на Linux без ручной установки пакета (#1223) 2026-10-10 22:01:46 +03:00
Fallout 5f8820d41f Перевод secret и donate на русский язык (#1419) 2026-10-10 21:44:18 +03:00
Flowseal 3cda0f5446 handle timeouted h2 connections, refill reply budget 2026-10-10 21:36:28 +03:00
Flowseal 59c3a6e3b3 fix h2 plain padding 2026-10-10 20:47:50 +03:00
Flowseal 9109570d32 fix commands on different keyboard layouts 2026-10-10 20:36:30 +03:00
Flowseal 00d72a4c7f fixes #1398 2026-10-10 19:00:34 +03:00
Flowseal a0a92f5e7e fix socketpair EOF 2026-10-10 18:54:09 +03:00
Flowseal a232472cbe fixes #1422 2026-10-09 18:54:03 +03:00
Flowseal 3fa281625f Optimizations 2026-10-06 23:21:03 +03:00
Flowseal 3c54c54d36 Optimizations 2026-10-06 19:15:27 +03:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ 03421197c8 Correct donate link (#1399) 2026-10-06 17:25:39 +03:00
Flowseal 18175fb4fe Version bump 2026-10-06 00:56:50 +03:00
Flowseal f742a8957d Correct handle of the 404 mtproto error 2026-10-06 00:56:20 +03:00
Flowseal 3686c36462 fixes #1392 2026-10-05 12:47:53 +03:00
Flowseal 4bdd69ade9 version bump 2026-10-05 02:31:01 +03:00
Flowseal 50c980e9ea WsPool refactoring, TCP fallback backoff, removed direct connections outside of a pool 2026-10-04 21:53:26 +03:00
Flowseal 48954ff9ab multiplexing media through cfproxy 2026-10-04 15:57:33 +03:00
Flowseal fe1580cd83 remove old docs path support 2026-10-04 15:12:26 +03:00
Flowseal ad7f3cae9e UI refactoring 2026-09-25 16:30:14 +03:00
Flowseal caa949bee0 fixes #1357 fixes #1354 2026-09-22 21:26:53 +03:00
Flowseal 3d59c813aa typo 2026-09-22 20:57:11 +03:00
Flowseal 70b982da2c Bump version 2026-09-19 22:51:47 +03:00
Flowseal 5226a409d4 clarify dialog message 2026-09-19 22:49:57 +03:00
Flowseal e519c4df4c use certifi for tls connections, fixes #1348 2026-09-19 22:48:25 +03:00
Flowseal a0605c7447 removed debug print closes #1350 2026-09-19 22:41:37 +03:00
62 changed files with 8040 additions and 2108 deletions

No files matched your search

+94 -7
View File
@@ -12,6 +12,11 @@ on:
description: "Release version tag (e.g. v1.0.0)"
required: false
default: "v1.0.0"
sign_windows:
description: 'Sign?'
type: boolean
required: false
default: true
build_windows_x64:
description: 'Build Windows x64?'
type: boolean
@@ -39,8 +44,15 @@ on:
default: true
permissions:
actions: read
contents: write
env:
SIGNPATH_ORGANIZATION_ID: d0164909-65ed-4ca8-aa9f-ec148a9f7950
SIGNPATH_PROJECT_SLUG: tg-ws-proxy
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: initial
SIGNPATH_SIGNING_POLICY: ${{ github.event.inputs.make_release == 'true' && 'release-signing' || 'test-signing' }}
jobs:
build-windows-x64:
runs-on: windows-latest
@@ -96,11 +108,36 @@ jobs:
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
- name: Upload artifact
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-x64
path: dist/TgWsProxy_windows.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-x64
path: dist/TgWsProxy_windows.exe
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows.exe
archive: true
if-no-files-found: error
build-windows-arm64:
runs-on: windows-11-arm
@@ -173,11 +210,36 @@ jobs:
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
- name: Upload artifact
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-arm64
path: dist/TgWsProxy_windows_arm64.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-arm64
path: dist/TgWsProxy_windows_arm64.exe
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_arm64.exe
archive: true
if-no-files-found: error
build-win7:
runs-on: windows-latest
@@ -227,11 +289,36 @@ jobs:
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
- name: Upload artifact
- name: Upload unsigned artifact
if: ${{ inputs.sign_windows }}
id: upload-unsigned
uses: actions/upload-artifact@v7
with:
name: unsigned-win7-${{ matrix.suffix }}
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
archive: true
if-no-files-found: error
- name: Sign EXE with SignPath
if: ${{ inputs.sign_windows }}
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-win7-${{ matrix.suffix }}
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
archive: true
if-no-files-found: error
build-macos:
runs-on: macos-latest
@@ -573,7 +660,7 @@ jobs:
name: "TG WS Proxy ${{ github.event.inputs.version }}"
body: |
---
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/Funding.md)
> [!TIP]
> Не можете скачать?
+1 -1
View File
@@ -15,7 +15,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi "httpx[http2]==0.28.1"
FROM python:3.12-slim AS runtime
-32
View File
@@ -1,32 +0,0 @@
# 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) за информацию.
-125
View File
@@ -1,125 +0,0 @@
# 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 });
},
};
```
+4 -2
View File
@@ -49,16 +49,18 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--secret` | `random` | 32-character hex key for client authorization |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Target IP for DC (can be specified multiple times) |
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--no-h2` | `false` | Disable HTTP/2 media multiplexing through CF proxy |
| `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. |
| `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. |
| `--no-secure` | `false` | Use 80 port for CF-proxy and CF-worker connections |
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--force-test-dc` | `false` | Route all traffic to Telegram test DCs [Learn more](./TestDc.md) |
| `--buf-kb` | `256` | Buffer size in KB |
| `--pool-size` | `4` | Number of pre-allocated connections per DC |
| `--pool-size` | `4` | Number of ready WS connections per DC. `0` disables the direct DC→IP WS route |
| `--log-file` | disabled | Path to file for saving logs |
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
| `--log-backups` | `0` | Number of log backups after overwrite |
| `--log-backups` | `1` | Number of rotated log files to keep (minimum 1) |
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
**Examples:**
+5
View File
@@ -26,6 +26,11 @@ Cloudflare limits the number of simultaneous WebSocket (WS) connections. The def
5. In the `TgWsProxy` settings, replace the default domain with your own.
## HTTP/2 media multiplexing
Multiplexing is enabled by default. Media connections share one TCP/TLS connection to Cloudflare per DC and domain, using `/api` instead of `/apiws`. Main connections and test DCs continue using WS. H2 requires CF proxy and TLS enabled.
Can be disabled with `--no-h2` or by unchecking the **Media multiplexing (HTTP/2)** in the **Cloudflare Proxy** settings.
## Credits / Acknowledgments
- Original Idea: https://github.com/Nekogram/WSProxy
+11
View File
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
## Updating
When update checks are enabled in settings, the app offers to update itself on startup and installs the new version:
- installed from `.deb`/`.rpm` — the new package is downloaded and installed by the package manager (via `pkexec`, password confirmation required);
- standalone binary — replaced in place if its directory is writable, otherwise also via `pkexec`.
The downloaded file is checked against the response length and the SHA-256 digest from the release; a corrupted download is never installed. The app restarts automatically after the update.
For AUR packages, `pip` installs, and non-x86_64 architectures the previous behaviour applies — the release page is opened.
## Configuring Telegram Desktop
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
+9 -4
View File
@@ -18,13 +18,18 @@ The tray application stores data in:
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"log_max_mb": 5,
"check_updates": true,
"cfproxy": true,
"cfproxy_user_domain": "",
"cfproxy_worker_domain": "",
"h2": true,
"cfproxy_user_domain_enabled": false,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": false,
"cfproxy_worker_domain": [],
"force_test_dc": false,
"appearance": "auto"
"no_secure": false,
"appearance": "auto",
"language": "en"
}
```
-11
View File
@@ -1,11 +0,0 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
Буду благодарен за любую форму поддержки! Спасибо ❤️
+4 -2
View File
@@ -49,16 +49,18 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--no-h2` | `false` | Отключить мультиплексирование медиа через HTTP/2 в CF-прокси |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--force-test-dc` | `false` | Направлять весь трафик на тестовые DC Telegram [Подробнее](./TestDc.md) |
| `--buf-kb` | `256` | Размер буфера в КБ |
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
| `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
| `--log-backups` | `1` | Сколько старых файлов логов хранить при ротации (минимум 1) |
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:**
+6
View File
@@ -26,6 +26,12 @@ Cloudflare имеет лимиты на одновременное количе
5. В настройках `TgWsProxy` замените домен на свой.
## Мультиплексирование медиа через HTTP/2
Мультиплексирование включено по умолчанию. Медиа-соединения используют `/api` вместо `/apiws` через одно общее TCP/TLS-соединение до Cloudflare для каждого DC и домена. Основные соединения и тестовые DC продолжают использовать WS.
Чтобы отключить мультиплексирование, добавьте `--no-h2` или снимите галочку
**Мультиплексирование медиа (HTTP/2)** в настройках **Cloudflare Proxy**.
## Благодарности
- Идея: https://github.com/Nekogram/WSProxy
+11
View File
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Обновление
Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию:
- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля);
- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`.
Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически.
Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза.
## Настройка Telegram Desktop
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
+9 -4
View File
@@ -18,13 +18,18 @@ Tray-приложение хранит данные в:
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"log_max_mb": 5,
"check_updates": true,
"cfproxy": true,
"cfproxy_user_domain": "",
"cfproxy_worker_domain": "",
"h2": true,
"cfproxy_user_domain_enabled": false,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": false,
"cfproxy_worker_domain": [],
"force_test_dc": false,
"appearance": "auto"
"no_secure": false,
"appearance": "auto",
"language": "ru"
}
```
+100 -62
View File
@@ -5,6 +5,7 @@ import subprocess
import sys
import threading
import time
import webbrowser
from typing import Optional
import customtkinter as ctk
@@ -12,29 +13,31 @@ import pyperclip
import pystray
from PIL import Image, ImageTk
from proxy import get_link_host
from proxy import __version__, get_link_host
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, check_update_async,
ctk_run_dialog, ensure_ctk_thread, ensure_dirs, load_config, load_icon,
log, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
from ui.i18n import set_language, t
from ui.i18n import t
from ui.settings_dialog import SettingsDialog
from utils.update_check import get_status, relaunch_env
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting = False
_update_flow_lock = threading.Lock()
# dialogs (tkinter messagebox)
@@ -158,68 +161,25 @@ def _edit_config_dialog() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
)
_original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None:
root.destroy()
done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish()
dialog = SettingsDialog(
ctk=ctk, root=root, widgets=widgets, config=_config,
defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=_finish,
restart=lambda config: restart_proxy(config, _show_error),
)
def on_save() -> None:
from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
messagebox.showerror(t("app.error_title"), merged, parent=root)
return
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
_ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_restore_ui_locale()
_finish()
return
save_config(merged)
_config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
t("dialog.restart_title"),
t("dialog.restart_body"),
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
ctk_run_dialog(_build)
@@ -259,6 +219,78 @@ def _show_first_run() -> None:
ctk_run_dialog(_build)
# update
def _restart_after_update() -> None:
from utils.linux_update import exe_path
stop_proxy()
release_lock()
exe = str(exe_path())
try:
subprocess.Popen(
[exe, *sys.argv[1:]], env=relaunch_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
except OSError as exc:
log.error("Failed to launch updated binary: %s", exc)
if acquire_lock():
start_proxy(_config, _show_error)
_show_error(t("update.error", msg=exc))
return
os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
if not _update_flow_lock.acquire(blocking=False):
return
def _work() -> None:
from utils.linux_update import asset_name, detect_install_kind
from utils.update_check import find_asset, RELEASES_PAGE_URL
try:
st = get_status()
if _exiting or not st["has_update"]:
return
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
kind = detect_install_kind() if IS_FROZEN else None
asset = find_asset(asset_name(kind)) if kind else None
page_url = st["html_url"] or RELEASES_PAGE_URL
if not asset:
if _ask_yes_no(t("update.ask_open", version=st["latest"]), t("app.update_title")):
webbrowser.open(page_url)
return
if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")):
_install_update(kind, asset, page_url)
except Exception:
log.exception("Update flow failed")
finally:
_update_flow_lock.release()
threading.Thread(target=_work, daemon=True, name="manual-update").start()
def _install_update(kind: str, asset: dict, page_url: str) -> None:
from utils.linux_update import apply_update
err = apply_update(
kind, asset["url"], digest=asset.get("digest", ""), log=log,
)
if err is None:
log.info("Update installed, restarting")
_restart_after_update()
return
log.error("Update failed: %s", err)
if _ask_yes_no(t("update.install_fail", error=err), t("app.update_title")):
webbrowser.open(page_url)
# tray menu
@@ -266,7 +298,7 @@ def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host)
return pystray.Menu(
items = [
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR,
@@ -275,7 +307,13 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit),
)
]
st = get_status()
if st["has_update"]:
items.insert(-2, pystray.MenuItem(
t("tray.update", current=__version__, new=st["latest"]), _trigger_update_flow,
))
return pystray.Menu(*items)
# entry point
@@ -298,7 +336,7 @@ def run_tray() -> None:
return
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
_show_first_run()
check_ipv6_warning(_show_info)
+11 -68
View File
@@ -87,9 +87,9 @@ from ui.ctk_tray_ui import (
install_tray_config_form,
populate_first_run_window,
tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.i18n import set_language, t
from ui.i18n import t
from ui.settings_dialog import SettingsDialog
from utils.tray_common import (
APP_DIR,
APP_NAME,
@@ -362,7 +362,6 @@ def _edit_config_dialog() -> None:
pady=frame_pad_y,
)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
original_language = _config.get("language", DEFAULT_CONFIG["language"])
log.info("Building settings form")
widgets = install_tray_config_form(
ctk,
@@ -371,14 +370,8 @@ def _edit_config_dialog() -> None:
cfg,
DEFAULT_CONFIG,
show_autostart=False,
on_language_change=_refresh_tray_menu,
)
log.info("Settings form built")
original_appearance = ctk.get_appearance_mode()
def restore_ui_locale() -> None:
set_language(original_language)
_refresh_tray_menu()
def finish() -> None:
global _settings_window
@@ -386,70 +379,20 @@ def _edit_config_dialog() -> None:
_settings_window = None
_hide_ctk_root()
def cancel() -> None:
ctk.set_appearance_mode(original_appearance)
restore_ui_locale()
finish()
dialog = SettingsDialog(
ctk=ctk, root=root, widgets=widgets, config=_config,
defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=finish,
restart=lambda config: restart_proxy(config, _show_error),
)
def save() -> None:
from tkinter import messagebox
merged = validate_config_form(
widgets,
DEFAULT_CONFIG,
include_autostart=False,
)
if isinstance(merged, str):
messagebox.showerror(t("app.error_title"), merged, parent=root)
return
merged["force_test_dc"] = _config.get(
"force_test_dc",
DEFAULT_CONFIG["force_test_dc"],
)
ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(key) != _config.get(key) for key in merged)
proxy_changed = any(
merged.get(key) != _config.get(key)
for key in merged
if key not in ui_only_keys
)
if not config_changed:
restore_ui_locale()
finish()
return
save_config(merged)
_config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged)
_refresh_tray_menu()
if not proxy_changed:
finish()
return
do_restart = messagebox.askyesno(
t("dialog.restart_title"),
t("dialog.restart_body"),
parent=root,
)
finish()
if do_restart:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error),
daemon=True,
name="proxy-restart",
).start()
root.protocol("WM_DELETE_WINDOW", cancel)
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
install_tray_config_buttons(
ctk,
footer,
theme,
on_save=save,
on_cancel=cancel,
on_save=dialog.save,
on_cancel=dialog.cancel,
)
_activate_app()
log.info("Settings window ready")
+7 -4
View File
@@ -2,15 +2,14 @@
import os
import glob
from PyInstaller.utils.hooks import collect_data_files
import certifi
block_cipher = None
# customtkinter ships JSON themes + assets that must be bundled
import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__)
certifi_datas = collect_data_files('certifi')
certifi_cafile = certifi.where()
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
@@ -25,8 +24,10 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=appindicator_binaries,
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')],
hiddenimports=[
'httpx',
'h2',
'pystray._appindicator',
'PIL._tkinter_finder',
'customtkinter',
@@ -61,6 +62,8 @@ a = Analysis(
cipher=block_cipher,
)
a.datas += [('certifi/cacert.pem', certifi_cafile, 'DATA')]
_required_libraries = {
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
'libgtk-3.so.0', 'libappindicator3.so.1',
+3 -1
View File
@@ -15,8 +15,10 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'httpx',
'h2',
'tkinter',
'customtkinter',
'pystray._darwin',
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 10, 3, 0),
prodvers=(1, 10, 3, 0),
filevers=(1, 11, 2, 0),
prodvers=(1, 11, 2, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[
StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.10.3.0'),
StringStruct(u'FileVersion', u'1.11.2.0'),
StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.10.3.0'),
StringStruct(u'ProductVersion', u'1.11.2.0'),
]
)
]
+3 -1
View File
@@ -18,8 +18,10 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'httpx',
'h2',
'pystray._win32',
'PIL._tkinter_finder',
'customtkinter',
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener
__version__ = "1.10.3"
__version__ = "1.11.2"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+94 -32
View File
@@ -1,8 +1,9 @@
import asyncio
import logging
import struct
import time
from typing import List, Optional
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlencode
from .utils import *
@@ -11,13 +12,24 @@ from .balancer import balancer
from .config import proxy_config
from .raw_websocket import RawWebSocket
from .pool import cf_worker_pool
from ._aes import Cipher, algorithms, modes
from .network_debug import WsActivity
from .cf_h2 import bridge_h2
log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I')
ZERO_64 = b'\x00' * 64
TCP_BACKOFF_INITIAL = 30.0
TCP_BACKOFF_MAX = 3600.0
_tcp_failures: Dict[Tuple[str, int], int] = {}
_tcp_retry_after: Dict[Tuple[str, int], float] = {}
_tcp_connecting: Set[Tuple[str, int]] = set()
def reset_tcp_backoff() -> None:
_tcp_failures.clear()
_tcp_retry_after.clear()
_tcp_connecting.clear()
class CryptoCtx:
@@ -35,44 +47,39 @@ class MsgSplitter:
Splits TCP stream data into individual MTProto transport packets
so each can be sent as a separate WS frame.
"""
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
__slots__ = ('_proto', '_cipher_buf', '_plain_buf', '_disabled')
def __init__(self, relay_init: bytes, proto_int: int):
cipher = Cipher(algorithms.AES(relay_init[8:40]),
modes.CTR(relay_init[40:56]))
self._dec = cipher.encryptor()
self._dec.update(ZERO_64)
def __init__(self, proto_int: int):
self._proto = proto_int
self._cipher_buf = bytearray()
self._plain_buf = bytearray()
self._disabled = False
def split(self, chunk: bytes) -> List[bytes]:
def split(self, chunk: bytes, plain: bytes) -> List[bytes]:
if not chunk:
return []
if self._disabled:
return [chunk]
self._cipher_buf.extend(chunk)
self._plain_buf.extend(self._dec.update(chunk))
self._plain_buf.extend(plain)
parts = []
offset = 0
buf_len = len(self._cipher_buf)
# Walk the buffer with an offset instead of deleting each packet from
# the front. Front-deletion on a bytearray shifts the remaining bytes,
# so a chunk holding many small packets degrades to O(N^2); a single
# trailing del keeps splitting O(N).
while offset < buf_len:
packet_len = self._next_packet_len(offset, buf_len - offset)
if packet_len is None:
break
if packet_len <= 0:
parts.append(bytes(self._cipher_buf[offset:]))
parts.append(bytes(self._cipher_buf) if offset == 0 else bytes(self._cipher_buf[offset:]))
offset = buf_len
self._disabled = True
break
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
if offset == 0 and packet_len == buf_len:
parts.append(bytes(self._cipher_buf))
else:
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
offset += packet_len
if offset:
@@ -130,7 +137,7 @@ class MsgSplitter:
async def do_fallback(reader, writer, relay_init, label,
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
ctx: CryptoCtx, splitter=None):
ctx: CryptoCtx, splitter=None, *, h2_pool=None, proto_tag=None):
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
fallback_dst = ip_table.get(dc)
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
@@ -154,6 +161,13 @@ async def do_fallback(reader, writer, relay_init, label,
if ok:
return True
elif method == 'cf':
if is_media and h2_pool is not None and proto_tag is not None:
channel = await h2_pool.open(dc, label)
if channel is not None:
stats.connections_h2 += 1
stats.connections_cfproxy += 1
await bridge_h2(reader, writer, channel, ctx, proto_tag)
return True
ok = await _cfproxy_fallback(
reader, writer, relay_init, label, ctx,
dc=dc, is_media=is_media,
@@ -161,8 +175,6 @@ async def do_fallback(reader, writer, relay_init, label,
if ok:
return True
elif method == 'tcp' and fallback_dst:
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
label, dc, media_tag, fallback_dst)
ok = await _tcp_fallback(
reader, writer, fallback_dst, 443,
relay_init, label, ctx)
@@ -231,6 +243,7 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
log.info("[%s] DC%d%s -> trying CF proxy",
label, dc, media_tag)
connect_started = time.monotonic()
for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}'
@@ -246,6 +259,8 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
if ws is None:
return False
log.debug('[%s] WS CONNECT DC%d%s route=cf setup_ms=%.0f',
label, dc, media_tag, (time.monotonic() - connect_started) * 1000)
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
log.info("[%s] Switched active CF domain", label)
@@ -258,19 +273,45 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
try:
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
except Exception as exc:
log.warning("[%s] TCP fallback to %s:%d failed: %s",
label, dst, port, repr(exc))
key = (dst, port)
if key in _tcp_connecting or time.monotonic() < _tcp_retry_after.get(key, 0):
log.debug("[%s] TCP fallback to %s:%d skipped (backoff or connecting)",
label, dst, port)
return False
stats.connections_tcp_fallback += 1
rw.write(relay_init)
await rw.drain()
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
return True
_tcp_connecting.add(key)
rw = None
try:
try:
log.info("[%s] TCP fallback to %s:%d", label, dst, port)
rr, rw = await asyncio.wait_for(
asyncio.open_connection(dst, port), timeout=10)
rw.write(relay_init)
await rw.drain()
except Exception as exc:
failures = _tcp_failures.get(key, 0) + 1
_tcp_failures[key] = failures
delay = min(TCP_BACKOFF_INITIAL * 2 ** min(failures - 1, 7),
TCP_BACKOFF_MAX)
_tcp_retry_after[key] = time.monotonic() + delay
log.warning("[%s] TCP fallback to %s:%d failed: %r; retry in %.0fs",
label, dst, port, exc, delay)
return False
finally:
_tcp_connecting.discard(key)
_tcp_failures.pop(key, None)
_tcp_retry_after.pop(key, None)
stats.connections_tcp_fallback += 1
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
return True
finally:
if rw is not None:
rw.close()
try:
await rw.wait_closed()
except (OSError, ConnectionError):
pass
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
@@ -290,6 +331,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
down_packets = 0
start_time = asyncio.get_running_loop().time()
close_reason = 'normal'
activity = WsActivity(label, dc_tag) if log.isEnabledFor(logging.DEBUG) else None
async def tcp_to_ws():
nonlocal up_bytes, up_packets, close_reason
@@ -300,24 +342,38 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
if splitter:
tail = splitter.flush()
if tail:
if activity is not None:
activity.send_started()
await ws.send(tail[0])
if activity is not None:
activity.sent(len(tail[0]))
break
n = len(chunk)
if activity is not None:
activity.input(n)
stats.bytes_up += n
up_bytes += n
up_packets += 1
plain = ctx.clt_dec.update(chunk)
chunk = ctx.tg_enc.update(plain)
if splitter:
parts = splitter.split(chunk)
parts = splitter.split(chunk, plain)
if not parts:
continue
if activity is not None:
activity.send_started()
if len(parts) > 1:
await ws.send_batch(parts)
else:
await ws.send(parts[0])
if activity is not None:
activity.sent(sum(map(len, parts)))
else:
if activity is not None:
activity.send_started()
await ws.send(chunk)
if activity is not None:
activity.sent(len(chunk))
except asyncio.CancelledError:
return
except (ConnectionError, OSError) as e:
@@ -336,6 +392,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
close_reason = 'upstream: ws_close'
break
n = len(data)
if activity is not None:
activity.received(n)
stats.bytes_down += n
down_bytes += n
down_packets += 1
@@ -343,6 +401,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
data = ctx.clt_enc.update(plain)
writer.write(data)
await writer.drain()
if activity is not None:
activity.delivered(n)
except asyncio.CancelledError:
return
except (ConnectionError, OSError) as e:
@@ -366,6 +426,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
except BaseException:
pass
elapsed = asyncio.get_running_loop().time() - start_time
if activity is not None:
activity.close()
log.info("[%s] %s WS session closed (%s): "
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag, close_reason,
+998
View File
@@ -0,0 +1,998 @@
from __future__ import annotations
import asyncio
import logging
import os
import struct
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
import httpx
from .balancer import balancer
from .h2_transport import H2Transport
from .stats import stats
from .utils import (
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
PROTO_TAG_SECURE,
)
log = logging.getLogger('tg-mtproto-proxy')
MAX_PACKET = 4 * 1024 * 1024
MAX_PLAIN_PADDING = 256
MAX_CHANNEL_REQUESTS = 8
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
MAX_LANE_REQUESTS = 64
MAX_LANE_BYTES = 32 * 1024 * 1024
MAX_LANE_REPLY_BYTES = 32 * 1024 * 1024
DOMAIN_COOLDOWN = 30.0
REQUEST_TIMEOUT = 65.0
SETUP_TIMEOUT = 8.0
REPLAY_IDLE_SECONDS = 1.0
REPLAY_REQUEST_SECONDS = 3.0
REPLAY_CHECK_SECONDS = 0.25
REPLAY_MAX_AGE = 30.0
REPLAY_HISTORY_BYTES = 64 * 1024
REPLAY_HISTORY_PACKETS = 16
MAX_CHANNEL_REPLAYS = 2
REPLAY_SLOT_SECONDS = 30.0
REPLAY_RETRY_SECONDS = 2.0
REPLAY_MAX_ATTEMPTS = 3
SLOW_RESPONSE_SECONDS = 3.0
DIAGNOSTIC_INTERVAL = 5.0
@dataclass
class _RequestTrace:
packet: int
started: float
replay: bool
size: int
request: int = 0
stream: int = 0
phase: str = 'queued'
sent_at: Optional[float] = None
headers_at: Optional[float] = None
finished_at: Optional[float] = None
received: int = 0
cf_ray: str = '-'
def summary(self, now: float) -> str:
end = self.finished_at if self.finished_at is not None else now
headers = self.headers_at if self.headers_at is not None else end
send_ms = -1 if self.sent_at is None else (self.sent_at - self.started) * 1000
headers_ms = -1 if self.sent_at is None else max(0, headers - self.sent_at) * 1000
return ('req=%d pkt=%d sid=%d replay=%d phase=%s age_ms=%.0f '
'send_ms=%.0f wait_headers_ms=%.0f up=%d down=%d ray=%s' % (
self.request, self.packet, self.stream, self.replay, self.phase,
(end - self.started) * 1000,
send_ms, headers_ms,
self.size, self.received, self.cf_ray))
class _NativeDeliveryError(ConnectionError):
"""The local receiver disconnected"""
class _ReplyBufferFull(BufferError):
"""A local memory limit"""
class _MTProtoTransportError(Exception):
"""An endpoint error that the native client must receive, not just EOF."""
def __init__(self, code: int, source: str):
super().__init__('%d (%s)' % (code, source))
self.code = code
@dataclass
class _ReplayPacket:
sent_at: float
body: bytes
sequence: int
attempts: int = 0
last_attempt_at: float = 0.0
pending: Optional[asyncio.Task] = None
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
retired: bool = False
reply_id: Optional[bytes] = None
@property
def replayed(self) -> bool:
return self.last_attempt_at > 0 or self.attempts > 0
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
async def plaintext(count: int) -> bytes:
return decryptor.update(await reader.readexactly(count))
if tag == PROTO_TAG_ABRIDGED:
first = (await plaintext(1))[0]
quick, words = bool(first & 0x80), first & 0x7f
if words == 0x7f:
words = int.from_bytes(await plaintext(3), 'little')
length = words * 4
else:
value, = struct.unpack('<I', await plaintext(4))
quick, length = bool(value & 0x80000000), value & 0x7fffffff
if not 24 <= length <= MAX_PACKET + (MAX_PLAIN_PADDING if tag == PROTO_TAG_SECURE else 0):
raise ValueError('native packet length outside supported range: %d' % length)
body = await plaintext(length)
if tag == PROTO_TAG_SECURE:
if body[:8] == b'\x00' * 8:
packet_length = 20 + int.from_bytes(body[16:20], 'little')
max_padding = MAX_PLAIN_PADDING
else:
packet_length = 24 + ((length - 24) // 16) * 16
max_padding = 15
padding = length - packet_length
if not 24 <= packet_length <= length or padding > max_padding:
raise ValueError('invalid padded native packet: frame=%d packet=%d padding=%d limit=%d' % (
length, packet_length, padding, max_padding))
body = body[:packet_length]
if len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid native packet alignment or size')
return body, quick
def _encode_reply(body: bytes, tag: bytes) -> bytes:
if not body or len(body) % 4 or len(body) > MAX_PACKET:
raise ValueError('invalid HTTP MTProto response length: %d' % len(body))
if tag == PROTO_TAG_ABRIDGED:
words = len(body) // 4
prefix = bytes([words]) if words < 0x7f else b'\x7f' + words.to_bytes(3, 'little')
return prefix + body
padding = os.urandom(os.urandom(1)[0] % 4) if tag == PROTO_TAG_SECURE else b''
return struct.pack('<I', len(body) + len(padding)) + body + padding
class _HttpChannel:
def __init__(self, lane, channel_id: int, label: str):
self.lane, self.channel_id, self.label = lane, channel_id, label
self.closed = False
self.transport_error: Optional[int] = None
self.pending: Dict[asyncio.Task, int] = {}
self.pending_since: Dict[asyncio.Task, float] = {}
self.sent_since: Dict[asyncio.Task, float] = {}
self.pending_bytes = 0
self.reply_bytes = 0
self.queue = asyncio.Queue(maxsize=16)
self.capacity = asyncio.Condition()
self.recovery_wakeup = asyncio.Event()
self.requests = self.up = self.down = self.quick_requests = 0
self.started = time.monotonic()
self.receiving_tasks: Set[asyncio.Task] = set()
self.delivering_since: Optional[float] = None
self.replay_pending: Set[asyncio.Task] = set()
self.replay_history: List[_ReplayPacket] = []
self.replay_key: Optional[bytes] = None
self.replay_requests = 0
self.poll_requests = 0
self.first_poll_gap = 0.0
self.replay_rotations = 0
self.capacity_rotations = 0
self.last_progress = self.started
self.last_native_send = self.started
self.close_lock = asyncio.Lock()
self.send_lock = asyncio.Lock()
self.close_done = False
self.request_traces: Dict[asyncio.Task, _RequestTrace] = {}
self.recent_traces = deque(maxlen=16)
self.upload_wait_since: Optional[float] = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
self.http_bytes = self.small_responses = self.large_responses = 0
self.flow_sample = (self.started, 0, 0, 0, 0, 0, 0, 0)
lane.channels.add(self)
@property
def receiving_http(self) -> int:
return len(self.receiving_tasks)
@property
def native_backpressure(self) -> bool:
return bool(self.reply_bytes) or self.delivering_since is not None
def _has_capacity(self, length: int) -> bool:
return len(self.pending) < MAX_CHANNEL_REQUESTS and self.pending_bytes + length <= MAX_CHANNEL_BYTES
def _wake_receiver(self) -> None:
if not self.closed and not self.pending and not self.native_backpressure:
self.recovery_wakeup.set()
def _overdue_original(self, entry: _ReplayPacket, now: float) -> bool:
return entry.retired or any(task in self.sent_since and task not in self.receiving_tasks
and now - self.sent_since[task] >= REPLAY_REQUEST_SECONDS
for task in entry.originals)
def _replay_limit(self, entry: _ReplayPacket) -> int:
return REPLAY_MAX_ATTEMPTS if entry.originals or entry.retired or entry is self.replay_history[-1] else 0
def _capacity_candidate(self, now: float):
for task, sent in sorted(self.sent_since.items(), key=lambda pair: (
pair[0] not in self.replay_pending, pair[1])):
if (task.done() or task in self.receiving_tasks
or now - sent < REPLAY_REQUEST_SECONDS):
continue
if task in self.replay_pending:
return task, None
entry = next((entry for entry in self.replay_history if task in entry.originals), None)
if entry is not None and now - entry.sent_at <= REPLAY_MAX_AGE:
return task, entry
return None
async def _make_upload_room(self, now: float) -> None:
if self.closed or self.upload_wait_since is None:
return
if self.upload_wait_phase == 'channel-capacity':
if self._has_capacity(self.upload_wait_bytes):
return
channels = [self]
elif self.upload_wait_phase == 'lane-capacity':
if self.lane._has_capacity(self.upload_wait_bytes):
return
channels = sorted(self.lane.channels, key=lambda channel: min(
channel.sent_since.values(), default=now))
else:
return
for channel in channels:
candidate = channel._capacity_candidate(now)
if candidate is None:
continue
task, entry = candidate
channel.sent_since.pop(task, None)
if entry is not None:
entry.retired = True
trace = channel.request_traces.get(task)
if trace is not None:
trace.phase = 'capacity-' + trace.phase
if now >= self.lane.next_capacity_log:
self.lane.next_capacity_log = now + DIAGNOSTIC_INTERVAL
log.debug('H2 CAPACITY lane=%d waiting_channel=%d release_channel=%d '
'reason=%s replay=%d wait_ms=%.0f inflight=%d',
self.lane.lane_id, self.channel_id, channel.channel_id,
self.upload_wait_phase, task in channel.replay_pending,
(now - self.upload_wait_since) * 1000, self.lane.inflight)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
channel.capacity_rotations += 1
self.lane.capacity_rotations += 1
return
def _replay_blocker(self, entry: _ReplayPacket, now: float) -> Optional[str]:
if not 0 <= now - entry.sent_at <= REPLAY_MAX_AGE:
return 'expired'
if entry.attempts >= self._replay_limit(entry):
return 'budget-used'
if self.native_backpressure:
return 'native-backpressure'
if entry.originals.intersection(self.receiving_tasks):
return 'original-body'
if entry.pending in self.receiving_tasks:
return 'replay-body'
if entry.replayed and now - entry.last_attempt_at < REPLAY_RETRY_SECONDS:
return 'backoff-or-progress'
if self._overdue_original(entry, now):
return None
if (entry is not self.replay_history[-1]
or any(task is not entry.pending for task in self.pending)):
return 'receiver-active'
if entry.replayed and now - self.last_progress < REPLAY_IDLE_SECONDS:
return 'backoff-or-progress'
return None
def _replay_due(self, entry: _ReplayPacket, now: float) -> bool:
return self._replay_blocker(entry, now) is None
def _wait_state(self, now: float) -> str:
oldest = min(self.pending_since.values(), default=now)
recovery_left = sum(max(0, self._replay_limit(entry) - entry.attempts)
for entry in self.replay_history if now - entry.sent_at <= REPLAY_MAX_AGE)
waiting_headers = sum(task not in self.replay_pending and task not in self.receiving_tasks
and now - since >= REPLAY_REQUEST_SECONDS
for task, since in self.pending_since.items())
return ('channel=%d pending=%d replays=%d receiving=%d ready_bytes=%d '
'native_write=%d idle_ms=%.0f oldest_ms=%.0f native_age_ms=%.0f '
'recovery_left=%d waiting_headers=%d' % (
self.channel_id, len(self.pending), len(self.replay_pending),
self.receiving_http, self.reply_bytes, self.delivering_since is not None,
(now - self.last_progress) * 1000, (now - oldest) * 1000,
(now - self.last_native_send) * 1000, recovery_left, waiting_headers))
def _request_detail(self, trace: _RequestTrace, now: float) -> str:
entry = next((entry for entry in self.replay_history if entry.sequence == trace.packet), None)
recovery = 'not-retained'
attempts = '-'
if entry is not None:
attempts = '%d/%d' % (entry.attempts, self._replay_limit(entry))
blocker = self._replay_blocker(entry, now)
if blocker is not None:
recovery = blocker
elif entry.pending is not None:
recovery = ('replay-body' if entry.pending in self.receiving_tasks else
'probe-%.0fms' % ((now - self.pending_since.get(entry.pending, now)) * 1000))
elif len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
recovery = 'replay-slots'
elif not self._has_capacity(len(entry.body)):
recovery = 'channel-capacity'
elif not self.lane._has_capacity(len(entry.body)):
recovery = 'lane-capacity'
elif self.send_lock.locked():
recovery = 'send-lock'
else:
recovery = 'eligible'
related = [item for item in self.recent_traces if item.packet == trace.packet][-2:]
return ('channel=%d %s attempts=%s recovery=%s recent=[%s]' % (
self.channel_id, trace.summary(now), attempts, recovery,
'; '.join(item.summary(now) for item in related)))
def _flow_state(self, now: float) -> str:
previous = self.flow_sample
current = (now, self.requests, self.replay_requests, self.http_bytes,
self.down, self.small_responses, self.large_responses, self.poll_requests)
self.flow_sample = current
poll_gap, self.first_poll_gap = self.first_poll_gap, 0.0
probes = [trace.summary(now) for trace in self.request_traces.values() if trace.replay][:2]
return ('channel=%d window_ms=%.0f requests=%d replays=%d http_down=%d native_down=%d '
'small=%d large=%d pending=%d ready=%d upload_wait=%s/%.0fms native_write_ms=%.0f '
'capacity_rotations=%d polls=%d first_poll_ms=%.0f probes=[%s]' % (
self.channel_id, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 7)),
len(self.pending), self.reply_bytes, self.upload_wait_phase,
0 if self.upload_wait_since is None else (now - self.upload_wait_since) * 1000,
0 if self.delivering_since is None else (now - self.delivering_since) * 1000,
self.capacity_rotations,
current[7] - previous[7], poll_gap * 1000,
'; '.join(probes)))
def _fail(self, error: Exception) -> None:
if self.closed:
return
if isinstance(error, _MTProtoTransportError):
self.transport_error = error.code
self.closed = True
self.recovery_wakeup.set()
while not self.queue.empty():
self.queue.get_nowait()
self.reply_bytes = 0
self.queue.put_nowait(error)
async def send(self, body: bytes, quick: bool, replay: bool = False) -> bool:
if replay and self.send_lock.locked():
return False
if not replay:
self.upload_wait_since = time.monotonic()
self.upload_wait_phase = 'send-lock'
self.upload_wait_bytes = len(body)
try:
async with self.send_lock:
return await self._send(body, quick, replay)
finally:
if not replay:
self.upload_wait_since = None
self.upload_wait_phase = '-'
self.upload_wait_bytes = 0
async def _send(self, body: bytes, quick: bool, replay: bool) -> bool:
async with self.capacity:
if replay and not self._has_capacity(len(body)):
return False
if not replay:
self.upload_wait_phase = 'channel-capacity'
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(len(body))),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 channel closed')
if not replay:
self.upload_wait_phase = 'lane-capacity'
if not await self.lane._reserve(len(body), wait=not replay):
return False
if self.closed:
await self.lane._release(len(body))
raise ConnectionError('H2 channel closed')
replay_packet = self._find_packet(body) if replay else None
now = time.monotonic()
if replay and (replay_packet is None or replay_packet.pending is not None
or not self._replay_due(replay_packet, now)):
await self.lane._release(len(body))
return False
self.pending_bytes += len(body)
self.requests += 1
self.replay_requests += int(replay)
self.up += len(body)
stats.bytes_up += len(body)
if not replay:
self.last_native_send = now
native_packet = self._remember_packet(body, now) if not replay else None
if quick:
self.quick_requests += 1
packet = replay_packet or native_packet
trace = (_RequestTrace(packet.sequence if packet is not None else self.requests,
now, replay, len(body))
if log.isEnabledFor(logging.DEBUG) else None)
task = asyncio.create_task(self._run_request(body, replay, packet, trace))
self.pending[task] = len(body)
self.pending_since[task] = now
if trace is not None:
self.request_traces[task] = trace
if native_packet is not None:
native_packet.originals.add(task)
self._trim_history(now)
if replay:
if not self._overdue_original(replay_packet, now):
self.poll_requests += 1
if not replay_packet.replayed:
self.first_poll_gap = max(self.first_poll_gap, now - self.last_progress)
self.replay_pending.add(task)
replay_packet.attempts += 1
replay_packet.last_attempt_at = now
replay_packet.pending = task
return True
def _find_packet(self, body: bytes) -> Optional[_ReplayPacket]:
return next((entry for entry in self.replay_history if entry.body == body), None)
def _remember_packet(self, body: bytes, now: float) -> Optional[_ReplayPacket]:
key = body[:8]
if key == b'\x00' * 8:
return None
if self.replay_key != key:
self.replay_history.clear()
self.replay_key = key
if len(body) > REPLAY_HISTORY_BYTES:
return None
packet = self._find_packet(body)
if packet is None:
packet = _ReplayPacket(now, body, self.requests)
self.replay_history.append(packet)
return packet
def _trim_history(self, now: float) -> None:
retained_bytes = sum(len(entry.body) for entry in self.replay_history)
while len(self.replay_history) > REPLAY_HISTORY_PACKETS or retained_bytes > REPLAY_HISTORY_BYTES:
victim = next((entry for entry in self.replay_history if not entry.originals
and not (entry.retired and entry.attempts < REPLAY_MAX_ATTEMPTS
and now - entry.sent_at <= REPLAY_MAX_AGE)),
self.replay_history[0])
self.replay_history.remove(victim)
retained_bytes -= len(victim.body)
async def _run_request(self, body: bytes, replay: bool, packet: Optional[_ReplayPacket],
trace: Optional[_RequestTrace]) -> None:
task = asyncio.current_task()
try:
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
self.last_progress = time.monotonic()
if reply and not self.closed:
if packet is not None and reply[:24] != packet.reply_id:
packet.reply_id = reply[:24]
packet.attempts = 0
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
raise BufferError('H2 client response queue exceeded bound')
self.reply_bytes += len(reply)
self.queue.put_nowait(reply)
if trace is not None:
trace.phase = 'complete'
except asyncio.CancelledError:
if trace is not None:
trace.phase = 'cancelled-' + trace.phase
raise
except _MTProtoTransportError as exc:
if trace is not None:
trace.phase = 'transport-error'
stats.h2_errors += 1
if not self.closed:
log.warning('[%s] H2 lane=%d channel=%d host=%s MTProto transport error=%s '
'request=%d encrypted=%d bytes=%d replay=%d; reporting to client',
self.label, self.lane.lane_id, self.channel_id, self.lane.host, exc,
trace.request if trace is not None else 0,
body[:8] != b'\x00' * 8, len(body), replay)
self._fail(exc)
except Exception as exc:
if trace is not None:
trace.phase = 'error-' + type(exc).__name__
stats.h2_errors += 1
if isinstance(exc, (httpx.HTTPError, OSError, ValueError, BufferError)):
log.warning('[%s] H2 lane=%d channel=%d request failed: %s: %s; '
'detail=[%s]; '
'closing native channel, reconnect may select another domain',
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc,
trace.summary(time.monotonic()) if trace is not None else '-')
else:
log.exception('[%s] H2 lane=%d channel=%d unexpected request failure',
self.label, self.lane.lane_id, self.channel_id)
self._fail(exc)
finally:
await self._finish_request(task, packet)
async def _finish_request(self, task: asyncio.Task, packet: Optional[_ReplayPacket] = None) -> None:
length = self.pending.pop(task, None)
if length is None:
return
trace = self.request_traces.pop(task, None)
if trace is not None:
trace.finished_at = time.monotonic()
self.recent_traces.append(trace)
self.pending_bytes -= length
self.pending_since.pop(task, None)
self.sent_since.pop(task, None)
self.replay_pending.discard(task)
if packet is None:
packet = next((entry for entry in self.replay_history
if task in entry.originals or entry.pending is task), None)
if packet is not None:
packet.originals.discard(task)
if packet.pending is task:
packet.pending = None
await self.lane._release(length)
async with self.capacity:
self.capacity.notify_all()
self._wake_receiver()
async def _recover(self) -> None:
while not self.closed:
wakeup = asyncio.create_task(self.recovery_wakeup.wait())
try:
# wait_for can swallow cancellation when the wakeup completes
# at the same time on Python 3.8-3.11 (CPython #86296).
await asyncio.wait((wakeup,), timeout=REPLAY_CHECK_SECONDS)
finally:
wakeup.cancel()
await asyncio.gather(wakeup, return_exceptions=True)
self.recovery_wakeup.clear()
now = time.monotonic()
self.lane.log_wait(now)
await self._make_upload_room(now)
if self.closed or self.native_backpressure:
continue
def priority(entry):
overdue = self._overdue_original(entry, now)
return (not overdue, entry.sequence if overdue else -entry.sequence)
candidates = sorted(self.replay_history, key=priority)
for entry in candidates:
if not self._replay_due(entry, now):
continue
replace = entry.pending
if replace is None and len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
replace = next((task for task in self.pending if task in self.replay_pending
and task not in self.receiving_tasks), None)
if replace is None:
continue
if replace is not None:
sent = self.sent_since.get(replace)
if (replace in self.receiving_tasks
or sent is None or now - sent < REPLAY_SLOT_SECONDS):
continue
replace.cancel()
await asyncio.gather(replace, return_exceptions=True)
self.replay_rotations += 1
await self.send(entry.body, False, replay=True)
break
async def receive(self) -> bytes:
value = await self.queue.get()
if isinstance(value, Exception):
raise value
self.reply_bytes -= len(value)
self._wake_receiver()
return value
async def close(self) -> None:
async with self.close_lock:
if self.close_done:
return
waiting = self._wait_state(time.monotonic())
if log.isEnabledFor(logging.DEBUG) and self.request_traces:
now = time.monotonic()
traces = sorted(self.request_traces.values(), key=lambda trace: trace.started)[:3]
log.debug('H2 DETAIL CLOSE lane=%d [%s]', self.lane.lane_id,
'; '.join(self._request_detail(trace, now) for trace in traces))
self._fail(ConnectionError('H2 channel closed'))
self.closed = True
tasks = list(self.pending)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
for task in list(self.pending):
await self._finish_request(task)
self.replay_pending.clear()
self.replay_history.clear()
self.sent_since.clear()
self.request_traces.clear()
self.recent_traces.clear()
self.lane.channels.discard(self)
self.close_done = True
log.debug('[%s] H2 channel=%d closed lane=%d requests=%d replays=%d rotations=%d capacity_rotations=%d up=%d down=%d '
'quickack=%d duration=%.1fs remaining_channels=%d; %s',
self.label, self.channel_id, self.lane.lane_id, self.requests,
self.replay_requests, self.replay_rotations, self.capacity_rotations, self.up, self.down, self.quick_requests,
time.monotonic() - self.started, len(self.lane.channels), waiting)
class _HttpLane:
def __init__(self, host: str, lane_id: int):
self.host, self.lane_id = host, lane_id
self.channels: Set[_HttpChannel] = set()
self.failed_until = 0.0
self.closed = False
self.tcp_connections = self.requests = self.responses = self.errors = 0
self.slow_headers = self.slow_bodies = self.replays = 0
self.max_headers_seconds = self.max_body_seconds = 0.0
self.next_wait_log = self.next_slow_log = 0.0
self.next_capacity_log = 0.0
self.capacity_rotations = 0
self.next_large_slow_log = 0.0
self.inflight = self.queued_bytes = self.max_inflight = 0
self.reply_buffer_bytes = 0
self.capacity = asyncio.Condition()
self.client = httpx.AsyncClient(
transport=H2Transport(max_streams=MAX_LANE_REQUESTS),
trust_env=False,
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
)
async def _trace(self, event: str, info: Dict) -> None:
if event == 'connection.connect_tcp.started':
self.tcp_connections += 1
stats.h2_tcp_connections += 1
log.debug('H2 lane=%d TCP_OPEN connection=%d host=%s channels=%d',
self.lane_id, self.tcp_connections, self.host, len(self.channels))
async def _preflight(self) -> None:
started = time.monotonic()
async with self.client.stream('HEAD', 'https://' + self.host + '/api',
extensions={'trace': self._trace}) as response:
if response.http_version != 'HTTP/2':
raise ValueError('CF /api preflight negotiated ' + response.http_version)
if not (200 <= response.status_code < 300 or response.status_code in (405, 501)):
raise ConnectionError('CF /api preflight HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
log.debug('H2 lane=%d ready host=%s status=%d ms=%.0f',
self.lane_id, self.host, response.status_code,
(time.monotonic() - started) * 1000)
def _has_capacity(self, length: int) -> bool:
return self.inflight < MAX_LANE_REQUESTS and self.queued_bytes + length <= MAX_LANE_BYTES
async def _reserve(self, length: int, wait: bool = True) -> bool:
async with self.capacity:
if not wait and not self._has_capacity(length):
return False
if wait:
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(length)),
REQUEST_TIMEOUT)
if self.closed:
raise ConnectionError('H2 lane closed')
self.inflight += 1
self.queued_bytes += length
self.max_inflight = max(self.max_inflight, self.inflight)
return True
async def _release(self, length: int) -> None:
async with self.capacity:
self.inflight -= 1
self.queued_bytes -= length
self.capacity.notify_all()
async def _post(self, body: bytes, channel_id: int, replay: bool = False,
channel: Optional[_HttpChannel] = None) -> bytes:
self.requests += 1
self.replays += int(replay)
stats.h2_requests += 1
request_id = self.requests
started = time.monotonic()
buffered = 0
receiving = False
request_task = asyncio.current_task()
trace = channel.request_traces.get(request_task) if channel is not None else None
if trace is not None:
trace.request = request_id
trace.phase = 'connect-or-slot'
async def request_trace(event, info):
await self._trace(event, info)
if event == 'http2.send_request_body.complete' and channel is not None:
channel.sent_since[request_task] = time.monotonic()
if trace is not None:
if event == 'connection.connect_tcp.started':
trace.phase = 'connect'
elif event == 'http2.wait_for_stream.started':
trace.phase = 'stream-slot'
elif event == 'http2.send_request_headers.started':
trace.stream = info['stream_id']
trace.phase = 'upload'
elif event == 'http2.send_request_body.complete':
trace.sent_at = time.monotonic()
trace.phase = 'headers' if trace.headers_at is None else 'body'
def response_started():
nonlocal receiving
if trace is not None and trace.headers_at is None:
trace.headers_at = time.monotonic()
trace.phase = 'body'
if channel is not None and not receiving:
channel.receiving_tasks.add(request_task)
channel.last_progress = time.monotonic()
receiving = True
try:
async with self.client.stream(
'POST', 'https://' + self.host + '/api', content=body,
headers={'content-type': 'application/octet-stream', 'accept-encoding': 'identity'},
extensions={'trace': request_trace,
'h2_response_started': response_started}) as response:
headers_at = time.monotonic()
if trace is not None:
trace.cf_ray = ''.join(c for c in response.headers.get('cf-ray', '-')[:64]
if c.isascii() and (c.isalnum() or c == '-'))
self.slow_headers += int(headers_at - started >= SLOW_RESPONSE_SECONDS)
self.max_headers_seconds = max(self.max_headers_seconds, headers_at - started)
if response.http_version != 'HTTP/2':
raise ValueError('CF /api downgraded to ' + response.http_version)
if response.status_code in (403, 404, 429, 444):
raise _MTProtoTransportError(-response.status_code, 'HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if response.status_code != 200:
raise ConnectionError('CF /api HTTP %d cf_ray=%s' % (
response.status_code, response.headers.get('cf-ray', '-')))
if 'text/html' in response.headers.get('content-type', '').lower():
raise ValueError('CF /api returned HTML instead of MTProto')
response_started()
length_header = response.headers.get('content-length')
declared_length = int(length_header) if length_header is not None else None
if declared_length is not None and not 0 <= declared_length <= MAX_PACKET:
raise BufferError('H2 response Content-Length exceeds native packet bound')
content = bytearray()
async for chunk in response.aiter_bytes():
if len(content) + len(chunk) > MAX_PACKET:
raise BufferError('H2 response exceeds native packet bound')
if self.reply_buffer_bytes + len(chunk) > MAX_LANE_REPLY_BYTES:
raise _ReplyBufferFull('H2 lane response buffers exceeded bound')
self.reply_buffer_bytes += len(chunk)
buffered += len(chunk)
content.extend(chunk)
if trace is not None:
trace.received += len(chunk)
if channel is not None:
channel.last_progress = time.monotonic()
received = len(content)
if (declared_length is not None
and response.headers.get('content-encoding', 'identity') == 'identity'
and received != declared_length):
raise ValueError('incomplete H2 response body')
if content and len(content) % 4:
raise ValueError('CF /api response is not an aligned MTProto packet')
if len(content) == 4:
code, = struct.unpack('<i', content)
if code < 0:
raise _MTProtoTransportError(code, 'HTTP 200 payload')
self.responses += 1
if channel is not None:
channel.http_bytes += received
channel.small_responses += int(received <= 4096)
channel.large_responses += int(received > 4096)
finished = time.monotonic()
body_seconds = finished - headers_at
self.max_body_seconds = max(self.max_body_seconds, body_seconds)
self.slow_bodies += int(body_seconds >= SLOW_RESPONSE_SECONDS)
next_slow = self.next_large_slow_log if received > 4096 else self.next_slow_log
if finished - started >= SLOW_RESPONSE_SECONDS and finished >= next_slow:
if received > 4096:
self.next_large_slow_log = finished + DIAGNOSTIC_INTERVAL
else:
self.next_slow_log = finished + DIAGNOSTIC_INTERVAL
log.debug('H2 SLOW lane=%d channel=%d request=%d bytes=%d replay=%d '
'headers_ms=%.0f body_ms=%.0f detail=[%s]',
self.lane_id, channel_id, request_id, received, replay,
(headers_at - started) * 1000, body_seconds * 1000,
trace.summary(finished) if trace is not None else '-')
return bytes(content)
except _MTProtoTransportError:
self.errors += 1
raise
except _ReplyBufferFull:
raise
except (httpx.HTTPError, OSError, ValueError, BufferError):
self.errors += 1
self.failed_until = time.monotonic() + DOMAIN_COOLDOWN
raise
finally:
self.reply_buffer_bytes -= buffered
if receiving:
channel.receiving_tasks.discard(request_task)
def log_wait(self, now: float) -> None:
if not log.isEnabledFor(logging.DEBUG) or now < self.next_wait_log:
return
self.next_wait_log = now + 1.0
waiting = [channel for channel in self.channels
if ((channel.delivering_since is not None
and now - channel.delivering_since >= SLOW_RESPONSE_SECONDS) or any(
task not in channel.replay_pending
and task not in channel.receiving_tasks
and now - started >= SLOW_RESPONSE_SECONDS
for task, started in channel.pending_since.items()))
and now - channel.last_native_send <= REPLAY_MAX_AGE]
if waiting:
self.next_wait_log = now + DIAGNOSTIC_INTERVAL
waiting.sort(key=lambda channel: channel.channel_id)
log.debug('H2 WAIT lane=%d affected=%d inflight=%d body_buffer=%d [%s]',
self.lane_id, len(waiting), self.inflight, self.reply_buffer_bytes,
'; '.join(channel._wait_state(now) for channel in waiting[:8]))
details = sorted(((channel, trace) for channel in waiting for trace in channel.request_traces.values()
if not trace.replay and now - trace.started >= SLOW_RESPONSE_SECONDS),
key=lambda pair: pair[1].started)[:12]
if details:
log.debug('H2 DETAIL lane=%d [%s]', self.lane_id,
'; '.join(channel._request_detail(trace, now) for channel, trace in details))
def log_flow(self, now: float) -> None:
if log.isEnabledFor(logging.DEBUG) and self.channels:
channels = sorted(self.channels, key=lambda channel: channel.channel_id)
for offset in range(0, len(channels), 8):
log.debug('H2 FLOW lane=%d [%s]', self.lane_id,
'; '.join(channel._flow_state(now) for channel in channels[offset:offset + 8]))
def summary(self) -> str:
return ('lane=%d host=%s channels=%d tcp_connections=%d requests=%d replies=%d '
'errors=%d inflight=%d max_inflight=%d queued_bytes=%d reply_buffer_bytes=%d '
'replays=%d capacity_rotations=%d slow_headers=%d slow_bodies=%d max_headers_ms=%.0f max_body_ms=%.0f' % (
self.lane_id, self.host, len(self.channels), self.tcp_connections,
self.requests, self.responses, self.errors, self.inflight,
self.max_inflight, self.queued_bytes, self.reply_buffer_bytes,
self.replays, self.capacity_rotations, self.slow_headers, self.slow_bodies,
self.max_headers_seconds * 1000, self.max_body_seconds * 1000))
async def close(self) -> None:
self.closed = True
async with self.capacity:
self.capacity.notify_all()
await asyncio.gather(*(channel.close() for channel in list(self.channels)))
await self.client.aclose()
log.debug('H2 CLOSED %s', self.summary())
class CfH2Pool:
def __init__(self):
for name in ('httpx', 'httpcore', 'hpack', 'h2'):
logging.getLogger(name).setLevel(logging.WARNING)
self.lanes: Dict[str, _HttpLane] = {}
self.locks: Dict[str, asyncio.Lock] = {}
self.failed_until: Dict[str, float] = {}
self.setup_retry_after: Dict[int, float] = {}
self.next_lane = self.next_channel = 0
self.closed = False
async def open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if time.monotonic() < self.setup_retry_after.get(dc, 0):
return None
try:
return await asyncio.wait_for(self._open(dc, label), SETUP_TIMEOUT)
except asyncio.TimeoutError:
self.setup_retry_after[dc] = time.monotonic() + DOMAIN_COOLDOWN
stats.h2_errors += 1
log.warning('[%s] H2 DC%d setup timed out; using WS for %.0fs',
label, dc, DOMAIN_COOLDOWN)
return None
async def _open(self, dc: int, label: str) -> Optional[_HttpChannel]:
if self.closed:
raise ConnectionError('H2 pool closed')
if dc not in DC_DEFAULT_IPS:
log.warning('[%s] H2 unsupported DC%d -> existing WS route', label, dc)
return None
for base_domain in balancer.get_domains_for_dc(dc):
host = 'kws%d.%s' % (dc, base_domain)
async with self.locks.setdefault(host, asyncio.Lock()):
if self.closed:
raise ConnectionError('H2 pool closed')
lane = self.lanes.get(host)
if time.monotonic() < max(self.failed_until.get(host, 0),
lane.failed_until if lane else 0):
continue
if lane is None:
self.next_lane += 1
lane = _HttpLane(host, self.next_lane)
try:
await lane._preflight()
except (httpx.HTTPError, OSError, ValueError) as exc:
stats.h2_errors += 1
self.failed_until[host] = time.monotonic() + DOMAIN_COOLDOWN
log.warning('[%s] H2 lane=%d preflight failed host=%s: %s: %s',
label, lane.lane_id, host, type(exc).__name__, exc)
await lane.close()
continue
except asyncio.CancelledError:
await lane.close()
raise
if self.closed:
await lane.close()
raise ConnectionError('H2 pool closed during preflight')
self.lanes[host] = lane
self.next_channel += 1
channel = _HttpChannel(lane, self.next_channel, label)
balancer.update_domain_for_dc(dc, base_domain)
log.info('[%s] ROUTE DC%d media -> H2 lane=%d channel=%d host=%s '
'channels=%d tcp_connections=%d', label, dc, lane.lane_id,
channel.channel_id, host, len(lane.channels), lane.tcp_connections)
return channel
log.debug('[%s] H2 DC%d unavailable; using WS', label, dc)
return None
def log_stats(self) -> None:
for lane in self.lanes.values():
if lane.channels:
log.debug('H2 STATS %s', lane.summary())
def log_flow(self, now: float) -> None:
for lane in self.lanes.values():
lane.log_flow(now)
async def close(self) -> None:
self.closed = True
await asyncio.gather(*(lane.close() for lane in self.lanes.values()))
self.lanes.clear()
async def bridge_h2(reader, writer, channel: _HttpChannel, ctx, tag: bytes) -> None:
async def write_native(data):
try:
writer.write(ctx.clt_enc.update(data))
await writer.drain()
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
async def upload() -> None:
while True:
try:
body, quick = await _read_packet(reader, ctx.clt_dec, tag)
except OSError as exc:
raise _NativeDeliveryError(str(exc)) from exc
await channel.send(body, quick)
async def download() -> None:
while True:
body = await channel.receive()
if channel.closed:
raise _NativeDeliveryError('H2 channel closed before native response')
channel.delivering_since = time.monotonic()
try:
await write_native(_encode_reply(body, tag))
finally:
channel.delivering_since = None
channel.down += len(body)
stats.bytes_down += len(body)
channel.last_progress = time.monotonic()
channel._wake_receiver()
tasks = [asyncio.create_task(upload()), asyncio.create_task(download()),
asyncio.create_task(channel._recover())]
try:
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if channel.transport_error is not None:
await write_native(_encode_reply(struct.pack('<i', channel.transport_error), tag))
channel.down += 4
stats.bytes_down += 4
else:
for task in done:
task.result()
except asyncio.IncompleteReadError:
log.debug('[%s] H2 channel=%d native EOF', channel.label, channel.channel_id)
except _NativeDeliveryError as exc:
log.debug('[%s] H2 channel=%d native closed: %s', channel.label, channel.channel_id, exc)
except (httpx.HTTPError, OSError, ValueError, BufferError, asyncio.TimeoutError) as exc:
if not channel.closed:
log.warning('[%s] H2 channel=%d bridge ended: %s: %s',
channel.label, channel.channel_id, type(exc).__name__, exc)
finally:
await channel.close()
+6
View File
@@ -70,11 +70,17 @@ class ProxyConfig:
fallback_cfproxy: bool = True
cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domains: List[str] = field(default_factory=list)
cfproxy_h2_media: bool = True
disable_secure: bool = False
fake_tls_domain: str = ''
proxy_protocol: bool = False
force_test_dc: bool = False
@property
def h2_enabled(self) -> bool:
return (self.cfproxy_h2_media and self.fallback_cfproxy
and not self.disable_secure and not self.force_test_dc)
proxy_config = ProxyConfig()
+84
View File
@@ -0,0 +1,84 @@
import asyncio
import logging
import signal
import socket
import sys
import threading
log = logging.getLogger('tg-mtproto-proxy')
def _replace_self_pipe(loop):
reader, writer = socket.socketpair()
try:
reader.setblocking(False)
writer.setblocking(False)
if threading.current_thread() is threading.main_thread():
previous_fd = signal.set_wakeup_fd(writer.fileno())
if previous_fd != loop._csock.fileno():
signal.set_wakeup_fd(previous_fd)
except BaseException:
reader.close()
writer.close()
raise
old_reader, old_writer = loop._ssock, loop._csock
loop._ssock, loop._csock = reader, writer
old_reader.close()
old_writer.close()
class _SelfPipeGuard:
"""Work around https://github.com/python/cpython/issues/156333"""
def __init__(self, loop):
self.loop = loop
self.read_self_pipe = loop._loop_self_reading
def __call__(self, future=None):
loop = self.loop
if future is not None:
if loop._self_reading_future is not future:
return
try:
data = future.result()
except OSError as exc:
reason = str(exc)
except BaseException:
return self.read_self_pipe(future)
else:
if data:
return self.read_self_pipe(future)
reason = 'EOF'
loop._self_reading_future = None
self._recover(reason)
return
return self.read_self_pipe(future)
def _recover(self, reason):
loop = self.loop
if loop.is_closed() or loop._self_reading_future is not None:
return
try:
_replace_self_pipe(loop)
except OSError as exc:
log.warning('Windows event loop wakeup socket recovery failed: %s; '
'retry in 1s', exc)
loop.call_later(1, self._recover, reason)
return
log.warning('Windows event loop wakeup socket lost (%s); recreated', reason)
self.read_self_pipe()
def install_self_pipe_guard(loop):
if sys.platform != 'win32' or not isinstance(loop, asyncio.ProactorEventLoop):
return
if not isinstance(loop._loop_self_reading, _SelfPipeGuard):
guard = _SelfPipeGuard(loop)
loop._loop_self_reading = guard
future = loop._self_reading_future
if future is not None:
future.remove_done_callback(guard.read_self_pipe)
future.add_done_callback(guard)
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
import asyncio
import time
from collections import deque
from functools import lru_cache
import httpx
from h2.config import H2Configuration
from h2.connection import H2Connection
from h2.errors import ErrorCodes
from h2.events import (
ConnectionTerminated, DataReceived, RemoteSettingsChanged,
ResponseReceived, StreamEnded, StreamReset, WindowUpdated,
)
from h2.exceptions import H2Error, StreamClosedError
from h2.settings import SettingCodes, Settings
from .utils import create_ssl_context
STREAM_RECEIVE_WINDOW = 256 * 1024
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
IO_BATCH_SIZE = 64 * 1024
@lru_cache(maxsize=1)
def _default_ssl_context():
context = create_ssl_context()
context.set_alpn_protocols(['h2'])
return context
class _ResponseStream(httpx.AsyncByteStream):
def __init__(self, connection, stream_id, read_timeout):
self.connection = connection
self.stream_id = stream_id
self.read_timeout = read_timeout
self.changed = asyncio.Event()
self.chunks = deque()
self.consumed_credit = 0
self.headers = None
self.response_started = None
self.error = None
self.ended = False
self.closed = False
self.extensions = {'http_version': b'HTTP/2', 'stream_id': stream_id}
async def wait(self):
try:
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
except asyncio.TimeoutError as exc:
error = httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id)
# An idle socket can remain established after the path stops
# delivering data. Reconnect once its last active request times
# out, while preserving unrelated requests and buffered replies.
if not any(stream is not self and not stream.ended
for stream in self.connection.streams.values()):
self.connection.fail(error)
raise error from exc
def check_error(self):
if self.error is not None:
raise self.error
async def __aiter__(self):
while not self.closed:
self.changed.clear()
self.check_error()
if self.chunks:
data, credit = self.chunks.popleft()
yield data
self.connection.consumed(self, credit)
elif self.ended:
return
else:
await self.wait()
async def aclose(self):
if not self.closed:
self.closed = True
self.chunks.clear()
self.connection.release(self)
self.changed.set()
class _Connection:
def __init__(self, reader, writer, max_streams, write_timeout):
self.reader, self.writer = reader, writer
self.max_streams = max_streams
self.write_timeout = write_timeout
self.h2 = H2Connection(config=H2Configuration(client_side=True))
self.h2.local_settings = Settings(
client=True, initial_values={SettingCodes.ENABLE_PUSH: 0,
SettingCodes.INITIAL_WINDOW_SIZE: STREAM_RECEIVE_WINDOW})
self.streams = {}
self.changed = asyncio.Event()
self.write_lock = asyncio.Lock()
self.settings_received = False
self.error = None
self.h2.initiate_connection()
self.h2.increment_flow_control_window(CONNECTION_RECEIVE_WINDOW - 65535)
self.flush()
self.reader_task = asyncio.create_task(self.read_loop())
def get_extra_info(self, name):
return self.writer.get_extra_info(name)
def flush(self):
data = self.h2.data_to_send()
if data:
self.writer.write(data)
return True
return False
async def drain(self):
try:
async with self.write_lock:
await asyncio.wait_for(self.writer.drain(), self.write_timeout)
except asyncio.TimeoutError as exc:
error = httpx.WriteTimeout('H2 connection write timed out')
self.fail(error)
raise error from exc
except OSError as exc:
error = httpx.WriteError(str(exc))
self.fail(error)
raise error from exc
def fail(self, error):
if self.error is None:
self.error = error
for stream in self.streams.values():
if not stream.ended:
stream.error = error
stream.changed.set()
self.changed.set()
self.writer.close()
async def read_loop(self):
try:
while self.error is None:
data = await self.reader.read(IO_BATCH_SIZE)
if not data:
raise httpx.ReadError('H2 peer closed the connection')
events = self.h2.receive_data(data)
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
connection_credit = 0
for event in events:
if isinstance(event, RemoteSettingsChanged):
self.settings_received = True
self.changed.set()
elif isinstance(event, WindowUpdated):
self.changed.set()
elif isinstance(event, ConnectionTerminated):
raise httpx.RemoteProtocolError('H2 GOAWAY error=%s last_stream=%s' % (
event.error_code, event.last_stream_id))
else:
stream = self.streams.get(getattr(event, 'stream_id', None))
if isinstance(event, DataReceived):
if event.flow_controlled_length and not terminated:
connection_credit += event.flow_controlled_length
if stream is not None:
if event.data:
stream.chunks.append((event.data, event.flow_controlled_length))
elif not terminated:
self.consumed(stream, event.flow_controlled_length)
elif stream is not None:
if isinstance(event, ResponseReceived):
stream.headers = event.headers
if stream.response_started is not None:
stream.response_started()
elif isinstance(event, StreamEnded):
stream.ended = True
elif isinstance(event, StreamReset):
stream.error = httpx.RemoteProtocolError(
'H2 stream %d reset: %s' % (event.stream_id, event.error_code))
self.changed.set()
if stream is not None:
stream.changed.set()
if connection_credit:
self.h2.increment_flow_control_window(connection_credit)
if self.flush():
await self.drain()
except asyncio.CancelledError:
self.fail(httpx.ReadError('H2 reader stopped'))
raise
except (OSError, H2Error, httpx.HTTPError) as exc:
error = exc if isinstance(exc, httpx.HTTPError) else httpx.RemoteProtocolError(str(exc))
self.fail(error)
def check_error(self):
if self.error is not None:
raise self.error
async def reserve(self, timeouts):
timeout = timeouts.get('pool')
deadline = None if timeout is None else time.monotonic() + timeout
while True:
self.changed.clear()
self.check_error()
if self.settings_received and len(self.streams) < min(
self.max_streams, self.h2.remote_settings.max_concurrent_streams):
stream_id = self.h2.get_next_available_stream_id()
stream = _ResponseStream(self, stream_id, timeouts.get('read'))
self.streams[stream_id] = stream
return stream
remaining = None if deadline is None else max(0, deadline - time.monotonic())
try:
await asyncio.wait_for(self.changed.wait(), remaining)
except asyncio.TimeoutError as exc:
raise httpx.PoolTimeout('H2 stream capacity exhausted') from exc
async def send_body(self, request, stream):
async for data in request.stream:
offset = 0
while offset < len(data):
self.changed.clear()
self.check_error()
stream.check_error()
count = min(len(data) - offset, IO_BATCH_SIZE,
self.h2.local_flow_control_window(stream.stream_id))
if count <= 0:
try:
await asyncio.wait_for(self.changed.wait(), self.write_timeout)
except asyncio.TimeoutError as exc:
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
continue
batch_end = offset + count
while offset < batch_end:
frame_end = min(batch_end, offset + self.h2.max_outbound_frame_size)
self.h2.send_data(stream.stream_id, data[offset:frame_end])
offset = frame_end
self.flush()
await self.drain()
self.h2.end_stream(stream.stream_id)
self.flush()
await self.drain()
async def request(self, request):
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('http2.wait_for_stream.started', {})
stream = await self.reserve(timeouts)
stream.response_started = request.extensions.get('h2_response_started')
stream_id = stream.stream_id
try:
headers = [(b':method', request.method.encode('ascii')),
(b':scheme', request.url.raw_scheme),
(b':authority', request.headers['host'].encode('ascii')),
(b':path', request.url.raw_path)]
headers.extend((k.lower(), v) for k, v in request.headers.raw if k.lower() not in (
b'host', b'connection', b'keep-alive', b'proxy-connection',
b'upgrade', b'transfer-encoding'))
self.h2.send_headers(stream_id, headers)
self.flush()
if trace:
await trace('http2.send_request_headers.started', {'stream_id': stream_id})
await self.send_body(request, stream)
if trace:
await trace('http2.send_request_body.complete', {'stream_id': stream_id})
while stream.headers is None:
stream.changed.clear()
stream.check_error()
await stream.wait()
stream.check_error()
status = next(int(v) for k, v in stream.headers if k == b':status')
return httpx.Response(status, headers=[(k, v) for k, v in stream.headers
if not k.startswith(b':')],
stream=stream, extensions=stream.extensions)
except H2Error as exc:
await stream.aclose()
raise httpx.LocalProtocolError(str(exc)) from exc
except BaseException:
await stream.aclose()
raise
def consumed(self, stream, credit):
if credit and not stream.ended and not stream.closed and self.error is None:
stream.consumed_credit += credit
if stream.chunks and stream.consumed_credit < IO_BATCH_SIZE:
return
try:
self.h2.increment_flow_control_window(stream.consumed_credit, stream.stream_id)
stream.consumed_credit = 0
self.flush()
except StreamClosedError:
pass
def release(self, stream):
self.streams.pop(stream.stream_id, None)
if self.error is None:
try:
self.h2.reset_stream(stream.stream_id, ErrorCodes.CANCEL)
self.flush()
except StreamClosedError:
pass
except OSError as exc:
self.fail(httpx.WriteError(str(exc)))
self.changed.set()
async def aclose(self):
self.fail(httpx.ReadError('H2 connection closed'))
self.reader_task.cancel()
await asyncio.gather(self.reader_task, return_exceptions=True)
try:
await asyncio.wait_for(self.writer.wait_closed(), 2)
except (OSError, asyncio.TimeoutError):
pass
class H2Transport(httpx.AsyncBaseTransport):
def __init__(self, ssl_context=None, max_streams=64):
if ssl_context is None:
self.ssl_context = _default_ssl_context()
else:
self.ssl_context = ssl_context
self.ssl_context.set_alpn_protocols(['h2'])
self.max_streams = max_streams
self.connection = None
self.origin = None
self.connect_lock = asyncio.Lock()
self.closed = False
async def handle_async_request(self, request):
origin = (request.url.host, request.url.port or 443)
if request.url.scheme != 'https' or self.origin not in (None, origin):
raise httpx.UnsupportedProtocol('CF H2 transport requires a single HTTPS origin')
async with self.connect_lock:
if self.closed:
raise httpx.ConnectError('H2 transport closed')
self.origin = origin
if self.connection is None or self.connection.error is not None:
if self.connection is not None:
await self.connection.aclose()
timeouts = request.extensions.get('timeout', {})
trace = request.extensions.get('trace')
if trace:
await trace('connection.connect_tcp.started', {})
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(
*origin, ssl=self.ssl_context, server_hostname=origin[0]),
timeouts.get('connect'))
if writer.get_extra_info('ssl_object').selected_alpn_protocol() != 'h2':
raise httpx.RemoteProtocolError('CF origin did not negotiate h2')
self.connection = _Connection(reader, writer, self.max_streams, timeouts.get('write'))
if trace:
await trace('connection.start_tls.complete', {'return_value': self.connection})
except BaseException as exc:
if self.connection is not None:
self.connection.fail(httpx.ConnectError('H2 connection setup interrupted'))
if writer is not None:
writer.close()
if isinstance(exc, asyncio.TimeoutError):
raise httpx.ConnectTimeout('H2 TLS connect timed out') from exc
if isinstance(exc, OSError):
raise httpx.ConnectError(str(exc)) from exc
raise
connection = self.connection
return await connection.request(request)
async def aclose(self):
self.closed = True
async with self.connect_lock:
if self.connection is not None:
await self.connection.aclose()
+88
View File
@@ -0,0 +1,88 @@
import logging
import time
from collections import defaultdict
log = logging.getLogger('tg-mtproto-proxy')
class WsActivity:
active = set()
def __init__(self, label, dc):
self.label, self.dc = label, dc
self.started = time.monotonic()
self.native_up = self.ws_up = self.ws_down = self.native_down = 0
self.first_input = self.first_reply = self.last_rx = self.last_tx = None
self.awaiting_rx = self.sending = self.writing_native = None
self.max_rx_gap = self.max_send = self.max_native_write = 0.0
self.sample = (self.started, 0, 0, 0, 0)
self.active.add(self)
log.debug('[%s] WS OPEN %s', self.label, self.dc)
def input(self, length):
self.native_up += length
if self.first_input is None:
self.first_input = time.monotonic()
def send_started(self):
self.sending = time.monotonic()
def sent(self, length):
now = time.monotonic()
self.ws_up += length
if self.sending is not None:
self.max_send = max(self.max_send, now - self.sending)
if self.awaiting_rx is None and (self.last_rx is None or self.last_rx < self.sending):
self.awaiting_rx = self.sending
self.last_tx, self.sending = now, None
def received(self, length):
now = time.monotonic()
self.ws_down += length
self.last_rx = now
if self.awaiting_rx is not None:
self.max_rx_gap = max(self.max_rx_gap, now - self.awaiting_rx)
if self.first_reply is None and self.first_input is not None:
self.first_reply = now - self.first_input
self.awaiting_rx = None
self.writing_native = now
def delivered(self, length):
self.native_down += length
if self.writing_native is not None:
self.max_native_write = max(self.max_native_write, time.monotonic() - self.writing_native)
self.writing_native = None
def summary(self, now):
current = (now, self.native_up, self.ws_up, self.ws_down, self.native_down)
previous, self.sample = self.sample, current
return ('client=%s window_ms=%.0f native_up=%d ws_up=%d ws_down=%d native_down=%d '
'rx_gap_ms=%.0f last_rx_ms=%.0f ws_write_ms=%.0f native_write_ms=%.0f' % (
self.label, (now - previous[0]) * 1000,
*(current[index] - previous[index] for index in range(1, 5)),
0 if self.awaiting_rx is None else (now - self.awaiting_rx) * 1000,
-1 if self.last_rx is None else (now - self.last_rx) * 1000,
0 if self.sending is None else (now - self.sending) * 1000,
0 if self.writing_native is None else (now - self.writing_native) * 1000))
def close(self):
now = time.monotonic()
self.active.discard(self)
log.debug('WS END %s %s first_reply_ms=%.0f max_rx_gap_ms=%.0f '
'max_ws_write_ms=%.0f max_native_write_ms=%.0f',
self.dc, self.summary(now),
-1 if self.first_reply is None else self.first_reply * 1000,
self.max_rx_gap * 1000, self.max_send * 1000, self.max_native_write * 1000)
def log_ws_flow(now):
if not log.isEnabledFor(logging.DEBUG):
return
groups = defaultdict(list)
for activity in WsActivity.active:
groups[activity.dc].append(activity)
for dc, activities in sorted(groups.items()):
activities.sort(key=lambda item: item.label)
for offset in range(0, len(activities), 8):
log.debug('WS FLOW %s [%s]', dc,
'; '.join(item.summary(now) for item in activities[offset:offset + 8]))
+122 -91
View File
@@ -5,17 +5,16 @@ import time
from collections import deque
from urllib.parse import urlencode
from typing import Dict, List, Optional, Tuple, Set
from typing import Dict, List, Optional, Tuple
from .raw_websocket import RawWebSocket, WsHandshakeError
from .stats import stats
from .config import proxy_config
from .utils import ws_domains, DC_DEFAULT_IPS
from .utils import ws_domains, DC_DEFAULT_IPS, WS_PATH, WS_PATH_TEST
log = logging.getLogger('tg-mtproto-proxy')
# TODO: domains handling is broken: wrong is_media flag causes tcp_reset after handshake,
# but initial connection is still established no matter what is is_media flag is set to
class _WsPool:
WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0
@@ -23,17 +22,20 @@ class _WsPool:
REFILL_BACKOFF_MAX = 3600.0
def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set()
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {}
self.try_fronting_first = True
self._idle: Dict[Tuple[int, bool, bool], deque] = {}
self._refilling: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._rotating: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool, bool], float] = {}
self.try_fronting_first = False
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str]
async def get(self, dc: int, is_media: bool, *, is_test_dc: bool = False
) -> Optional[RawWebSocket]:
key = (dc, is_media)
target_ip = proxy_config.dc_redirects.get(dc)
if not target_ip or proxy_config.pool_size <= 0:
return None
key = (dc, is_media, is_test_dc)
domains = ws_domains(dc, is_media)
now = time.monotonic()
bucket = self._idle.get(key)
@@ -43,14 +45,12 @@ class _WsPool:
while bucket:
ws, created = bucket.popleft()
age = now - created
if (age > self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing()):
if self._is_stale(ws, created, now):
asyncio.create_task(self._quiet_close(ws))
continue
stats.pool_hits += 1
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self.report_success(dc, is_media)
log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains)
return ws
@@ -58,20 +58,25 @@ class _WsPool:
self._schedule_refill(key, target_ip, domains)
return None
def _is_stale(self, ws, created, now):
return (now - created >= self.WS_POOL_MAX_AGE or ws._closed
or ws.writer.transport.is_closing() or ws.reader.at_eof()
or ws.reader.exception() is not None)
def _schedule_refill(self, key, target_ip, domains):
if proxy_config.pool_size <= 0:
return
self._schedule_rotation(key, target_ip, domains)
if (key in self._refilling
or time.monotonic() < self._refill_after.get(key, 0)):
return
self._refilling.add(key)
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)
self._refilling[key] = asyncio.create_task(
self._refill(key, target_ip, domains))
async def _refill(self, key, target_ip, domains):
dc, is_media = key
dc, is_media, is_test_dc = key
tasks = []
adopted = set()
try:
bucket = self._idle.setdefault(key, deque())
needed = proxy_config.pool_size - len(bucket)
@@ -79,19 +84,23 @@ class _WsPool:
return
connected = 0
tasks = [asyncio.create_task(
self._connect_one(target_ip, domains))
self._connect_one(target_ip, domains,
WS_PATH_TEST if is_test_dc else WS_PATH))
for _ in range(needed)]
for t in tasks:
for t in asyncio.as_completed(tasks):
try:
ws = await t
if ws:
if self._refilling.get(key) is not asyncio.current_task():
return
bucket.append((ws, time.monotonic()))
adopted.add(ws)
connected += 1
self._schedule_rotation(key, target_ip, domains)
except Exception:
pass
except Exception as exc:
log.debug("WS pool connect failed: %r", exc)
if connected:
self.report_success(dc, is_media)
self._refill_failures.pop(key, None)
self._refill_after.pop(key, None)
else:
failures = self._refill_failures.get(key, 0) + 1
self._refill_failures[key] = failures
@@ -102,12 +111,20 @@ class _WsPool:
)
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",
dc, 'm' if is_media else '', len(bucket))
"WS pool refill failed for DC%d%s%s, retry in %.0fs",
dc, 't' if is_test_dc else '', 'm' if is_media else '', delay)
log.debug("WS pool refilled DC%d%s%s: %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '', len(bucket))
finally:
self._refilling.discard(key)
for task in tasks:
if not task.done():
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for ws in results:
if ws is not None and not isinstance(ws, BaseException) and ws not in adopted:
await self._quiet_close(ws)
if self._refilling.get(key) is asyncio.current_task():
self._refilling.pop(key, None)
def _schedule_rotation(self, key, target_ip, domains):
if key in self._rotating:
@@ -116,28 +133,16 @@ class _WsPool:
self._rotate(key, target_ip, domains))
async def _rotate(self, key, target_ip, domains):
dc, is_media = key
dc, is_media, is_test_dc = 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())))
while proxy_config.pool_size > 0:
bucket = self._idle.setdefault(key, deque())
now = time.monotonic()
expired = []
ready = deque()
while bucket:
ws, created = bucket.popleft()
if (now - created >= self.WS_POOL_MAX_AGE
or ws._closed
or ws.writer.transport.is_closing()):
if self._is_stale(ws, created, now):
expired.append(ws)
else:
ready.append((ws, created))
@@ -147,49 +152,45 @@ class _WsPool:
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))
"WS pool rotated DC%d%s%s: %d stale, %d ready",
dc, 't' if is_test_dc else '', 'm' if is_media else '',
len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains)
wake_at = now + self.WS_POOL_CHECK_INTERVAL
if bucket:
wake_at = min(wake_at, min(
created + self.WS_POOL_MAX_AGE for _, created in bucket))
if key not in self._refilling and self._refill_after.get(key, 0) > now:
wake_at = min(wake_at, self._refill_after[key])
await asyncio.sleep(max(0, wake_at - time.monotonic()))
finally:
if self._rotating.get(key) is asyncio.current_task():
self._rotating.pop(key, None)
async def _connect_one(self, target_ip, domains) -> Optional[RawWebSocket]:
async def _connect_one(self, target_ip, domains, path=WS_PATH) -> Optional[RawWebSocket]:
for domain in domains:
if self.try_fronting_first:
ws = await self._connect_fronted(target_ip, domain)
if ws:
return ws
try:
ws = await RawWebSocket.connect(
target_ip, domain, timeout=8)
self.try_fronting_first = False
return ws
except (asyncio.TimeoutError, ConnectionResetError):
if self.try_fronting_first:
return None
return await self._connect_fronted(target_ip, domain)
except WsHandshakeError as exc:
if exc.is_redirect:
modes = (True, False) if self.try_fronting_first else (False, True)
for fronted in modes:
try:
ws = await RawWebSocket.connect(
target_ip, domain, timeout=7 if fronted else 8, path=path,
sni="sprinthost.ru" if fronted else None)
except Exception as exc:
stats.ws_errors += 1
log.debug("WS pool connect %s%s via %s (fronting=%s): %r",
domain, path, target_ip, fronted, exc)
continue
return None
except Exception:
return None
self.try_fronting_first = fronted
if fronted:
stats.connections_fronting += 1
log.debug("WS pool connected %s%s via %s (fronting=%s)",
domain, path, target_ip, fronted)
return ws
return None
async def _connect_fronted(self, target_ip, domain) -> Optional[RawWebSocket]:
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:
await ws.close()
@@ -202,14 +203,21 @@ class _WsPool:
continue
for is_media in (False, True):
domains = ws_domains(dc, is_media)
self._schedule_refill((dc, is_media), target_ip, domains)
key = (dc, is_media, proxy_config.force_test_dc)
self._schedule_refill(key, target_ip, domains)
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
def reset(self):
loop = asyncio.get_running_loop()
for task in self._rotating.values():
for task in list(self._rotating.values()) + list(self._refilling.values()):
if not task.done() and task.get_loop() is loop:
task.cancel()
for bucket in self._idle.values():
for ws, _ in bucket:
try:
ws.writer.close()
except Exception as exc:
log.debug("WS pool close failed: %r", exc)
self._idle.clear()
self._refilling.clear()
self._rotating.clear()
@@ -217,6 +225,11 @@ class _WsPool:
self._refill_after.clear()
self.try_fronting_first = False
async def close(self):
tasks = list(self._rotating.values()) + list(self._refilling.values())
self.reset()
await asyncio.gather(*tasks, return_exceptions=True)
class _CfWorkerPool:
WS_POOL_MAX_AGE = 100.0
@@ -224,7 +237,7 @@ class _CfWorkerPool:
def __init__(self):
self._idle: Dict[int, deque] = {}
self._refilling: Set[int] = set()
self._refilling: Dict[int, asyncio.Task] = {}
self._exhausted_until: Dict[str, float] = {}
async def get(self, dc: int, fallback_dst: str,
@@ -256,8 +269,7 @@ class _CfWorkerPool:
def _schedule_refill(self, dc, fallback_dst, worker_domains):
if dc in self._refilling:
return
self._refilling.add(dc)
asyncio.create_task(self._refill(
self._refilling[dc] = asyncio.create_task(self._refill(
dc, fallback_dst, list(worker_domains)))
async def _refill(self, dc, fallback_dst, worker_domains):
@@ -274,11 +286,15 @@ class _CfWorkerPool:
if connected is None:
break
ws, worker_domain = connected
if self._refilling.get(dc) is not asyncio.current_task():
await self._quiet_close(ws)
return
bucket.append((ws, time.monotonic(), worker_domain))
log.debug("CF worker pool refilled DC%d: %d ready",
dc, len(bucket))
finally:
self._refilling.discard(dc)
if self._refilling.get(dc) is asyncio.current_task():
self._refilling.pop(dc, None)
async def _connect_one(self, worker_domains, fallback_dst, dc):
query = urlencode({
@@ -346,10 +362,25 @@ class _CfWorkerPool:
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
def reset(self):
loop = asyncio.get_running_loop()
for task in self._refilling.values():
if not task.done() and task.get_loop() is loop:
task.cancel()
for bucket in self._idle.values():
for ws, _, _ in bucket:
try:
ws.writer.close()
except Exception as exc:
log.debug("CF worker pool close failed: %r", exc)
self._idle.clear()
self._refilling.clear()
self._exhausted_until.clear()
async def close(self):
tasks = list(self._refilling.values())
self.reset()
await asyncio.gather(*tasks, return_exceptions=True)
ws_pool = _WsPool()
cf_worker_pool = _CfWorkerPool()
+55 -52
View File
@@ -1,5 +1,4 @@
import os
import ssl
import logging
import base64
import struct
@@ -8,6 +7,7 @@ import socket as _socket
from typing import List, Optional, Tuple
from .config import proxy_config
from .utils import create_ssl_context
log = logging.getLogger('tg-mtproto-proxy')
@@ -21,9 +21,10 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
_st_H = struct.Struct('>H')
_st_Q = struct.Struct('>Q')
_ssl_ctx = ssl.create_default_context()
_ssl_ctx_fronting = ssl.create_default_context()
_ssl_ctx_fronting.check_hostname = False
_XOR_TABLES = tuple(bytes(value ^ key for value in range(256)) for key in range(256))
_ssl_ctx = create_ssl_context()
_ssl_ctx_fronting = create_ssl_context(check_hostname=False)
class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str,
@@ -43,6 +44,11 @@ def _xor_mask(data: bytes, mask: bytes) -> bytes:
if not data:
return data
n = len(data)
if n >= 512:
masked = bytearray(data)
for offset in range(4):
masked[offset::4] = masked[offset::4].translate(_XOR_TABLES[mask[offset]])
return bytes(masked)
mask_rep = (mask * (n // 4 + 1))[:n]
return (int.from_bytes(data, 'big') ^
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
@@ -87,8 +93,7 @@ class RawWebSocket:
async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws', *,
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
ssl = _ssl_ctx_fronting if sni else _ssl_ctx
print(f"Connecting to {host} with secure={secure}, sni={sni}, path={path}")
ssl_context = _ssl_ctx_fronting if sni else _ssl_ctx
if sni is None:
sni = domain
@@ -97,7 +102,7 @@ class RawWebSocket:
(
asyncio.open_connection(
host, 443,
ssl=ssl,
ssl=ssl_context,
server_hostname=sni,
)
if secure
@@ -106,26 +111,26 @@ class RawWebSocket:
timeout=min(timeout, 10),
)
set_sock_opts(writer.transport, proxy_config.buffer_size)
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'\r\n'
)
writer.write(req.encode())
await writer.drain()
response_lines: list[str] = []
try:
set_sock_opts(writer.transport, proxy_config.buffer_size)
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {domain}\r\n'
f'Upgrade: websocket\r\n'
f'Connection: Upgrade\r\n'
f'Sec-WebSocket-Key: {ws_key}\r\n'
f'Sec-WebSocket-Version: 13\r\n'
f'Sec-WebSocket-Protocol: binary\r\n'
f'\r\n'
)
writer.write(req.encode())
await writer.drain()
response_lines: list[str] = []
while True:
line = await asyncio.wait_for(reader.readline(),
timeout=timeout)
@@ -133,34 +138,32 @@ class RawWebSocket:
break
response_lines.append(
line.decode('utf-8', errors='replace').strip())
except asyncio.TimeoutError:
if not response_lines:
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
except BaseException:
writer.close()
raise
if not response_lines:
writer.close()
raise WsHandshakeError(0, 'empty response')
first_line = response_lines[0]
parts = first_line.split(' ', 2)
try:
status_code = int(parts[1]) if len(parts) >= 2 else 0
except ValueError:
status_code = 0
if status_code == 101:
return RawWebSocket(reader, writer)
headers: dict[str, str] = {}
for hl in response_lines[1:]:
if ':' in hl:
k, v = hl.split(':', 1)
headers[k.strip().lower()] = v.strip()
writer.close()
raise WsHandshakeError(status_code, first_line, headers,
location=headers.get('location'))
async def send(self, data: bytes):
if self._closed:
raise ConnectionError("WebSocket closed")
@@ -291,4 +294,4 @@ class RawWebSocket:
payload = await self.reader.readexactly(length)
return opcode, _xor_mask(payload, mask_key), fin
payload = await self.reader.readexactly(length)
return opcode, payload, fin
return opcode, payload, fin
+9 -1
View File
@@ -5,6 +5,10 @@ class _Stats:
self.connections_total = 0
self.connections_active = 0
self.connections_ws = 0
self.connections_h2 = 0
self.h2_tcp_connections = 0
self.h2_requests = 0
self.h2_errors = 0
self.connections_tcp_fallback = 0
self.connections_cfproxy = 0
self.connections_fronting = 0
@@ -28,6 +32,10 @@ class _Stats:
return (f"total={self.connections_total} "
f"active={self.connections_active} "
f"ws={self.connections_ws} "
f"h2={self.connections_h2} "
f"h2_tcp={self.h2_tcp_connections} "
f"h2_req={self.h2_requests} "
f"h2_err={self.h2_errors} "
f"tcp_fb={self.connections_tcp_fallback} "
f"cf={self.connections_cfproxy} "
f"front={self.connections_fronting} "
@@ -40,4 +48,4 @@ class _Stats:
f"down={human_bytes(self.bytes_down)}")
stats = _Stats()
stats = _Stats()
+72 -146
View File
@@ -8,10 +8,9 @@ import asyncio
import hashlib
import argparse
import logging
import logging.handlers
import socket as _socket
from typing import Dict, Optional, Set, Tuple
from typing import Optional, Set, Tuple
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
@@ -23,24 +22,22 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
from .utils import *
from .stats import stats
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, coerce_domain_list
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt, reset_tcp_backoff
from .raw_websocket import set_sock_opts
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
from .balancer import balancer
from .pool import ws_pool, cf_worker_pool
from .cf_h2 import CfH2Pool
from .network_debug import log_ws_flow
from .event_loop import install_self_pipe_guard
from ._aes import Cipher, algorithms, modes
log = logging.getLogger('tg-mtproto-proxy')
IP_FAIL_COOLDOWN = 3600.0
DC_FAIL_COOLDOWN = 60.0
WS_FAIL_TIMEOUT = 2.0
LISTENER_CHECK_INTERVAL = 5.0
LISTENER_RESTART_DELAY = 1.0
ws_blacklist: Set[str] = set()
dc_fail_until: Dict[str, float] = {}
ip_fail_until: Dict[str, float] = {}
cf_h2_pool: Optional[CfH2Pool] = None
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
@@ -296,140 +293,32 @@ async def _handle_client(reader, writer, secret: bytes):
relay_init = _generate_relay_init(proto_tag, dc_idx)
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
dc_key = f'{dc}{"t" if is_test_dc else ""}{"m" 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
domains = ws_domains(dc, is_media)
ws = None
# 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):
if dc not in proxy_config.dc_redirects:
log.info("[%s] DC%d not in config -> fallback",
label, dc)
elif dc_key in ws_blacklist:
log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag)
else:
# Try to get WS from pool first, might be accidental timeout
ws = await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if not ws:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
label, dc, media_tag, target)
else:
log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
label, dc, media_tag, target)
if not ws:
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
ws_failed_redirect = False
ws_timed_out = False
all_redirects = True
ws = ws or await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if ws:
log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target)
else:
for domain in domains:
url = f'wss://{domain}{ws_path}'
log.info("[%s] DC%d%s -> %s via %s",
label, dc, media_tag, url, target)
try:
ws = await RawWebSocket.connect(target, domain,
timeout=ws_timeout,
path=ws_path)
all_redirects = False
break
except WsHandshakeError as exc:
stats.ws_errors += 1
if exc.is_redirect:
ws_failed_redirect = True
log.warning("[%s] DC%d%s got %d from %s -> %s",
label, dc, media_tag,
exc.status_code, domain,
exc.location or '?')
continue
else:
all_redirects = False
log.warning("[%s] DC%d%s WS handshake: %s",
label, dc, media_tag, exc.status_line)
except asyncio.TimeoutError:
stats.ws_errors += 1
ws_timed_out = True
log.warning("[%s] DC%d%s WS connect timed out via %s",
label, dc, media_tag, domain)
break
except Exception as exc:
stats.ws_errors += 1
all_redirects = False
log.warning("[%s] DC%d%s WS connect failed: %s",
label, dc, media_tag, repr(exc))
# WS failed -> fallback
ws = await ws_pool.get(dc, is_media, is_test_dc=is_test_dc)
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:
ws_blacklist.add(dc_key)
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
label, dc, media_tag)
elif ws_failed_redirect:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS failed for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
log.info("[%s] DC%d%s WS pool unavailable -> fallback",
label, dc, media_tag)
splitter_fb = None
try:
splitter_fb = MsgSplitter(relay_init, proto_int)
splitter_fb = MsgSplitter(proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter_fb)
ctx, splitter=splitter_fb, h2_pool=cf_h2_pool, proto_tag=proto_tag)
if ok:
log.info("[%s] DC%d%s fallback closed",
label, dc, media_tag)
log.info("[%s] DC%d%s fallback closed", label, dc, media_tag)
else:
log.warning("[%s] DC%d%s no fallback available", label, dc, media_tag)
return
ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media)
log.info("[%s] DC%d%s -> WS pool hit", label, dc, media_tag)
stats.connections_ws += 1
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
splitter = MsgSplitter(proto_int)
log.debug("[%s] MsgSplitter activated for proto 0x%08X",
label, proto_int)
except Exception:
@@ -471,15 +360,15 @@ _client_tasks: Set[asyncio.Task] = set()
async def _run(stop_event: Optional[asyncio.Event] = None):
global _server_instance, _server_stop_event
global _server_instance, _server_stop_event, cf_h2_pool
install_self_pipe_guard(asyncio.get_running_loop())
_server_stop_event = stop_event
reset_tcp_backoff()
ws_pool.reset()
cf_worker_pool.reset()
ws_blacklist.clear()
dc_fail_until.clear()
ip_fail_until.clear()
_client_tasks.clear()
cf_h2_pool = CfH2Pool() if proxy_config.h2_enabled else None
user_cf_domains = proxy_config.cfproxy_user_domains
if user_cf_domains:
@@ -488,8 +377,12 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
start_cfproxy_domain_refresh()
secret_bytes = bytes.fromhex(proxy_config.secret)
stopping = False
def client_cb(r, w):
if stopping:
w.close()
return
task = asyncio.create_task(_handle_client(r, w, secret_bytes))
_client_tasks.add(task)
task.add_done_callback(_client_tasks.discard)
@@ -531,6 +424,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)",
", ".join(proxy_config.cfproxy_worker_domains))
if cf_h2_pool is not None:
log.info(' CF H2 media: enabled')
if proxy_config.disable_secure:
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
log.info("=" * 60)
@@ -542,11 +437,25 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
log.info("=" * 60)
async def log_stats():
next_stats = time.monotonic() + 60
next_tick = time.monotonic() + 5
try:
while True:
await asyncio.sleep(60)
bl = ', '.join(f'DC{k}' for k in sorted(ws_blacklist)) or 'none'
log.info("stats: %s | ws_bl: %s", stats.summary(), bl)
await asyncio.sleep(5)
now = time.monotonic()
if log.isEnabledFor(logging.DEBUG):
if now - next_tick >= .25:
log.debug('NET LOOP late_ms=%.0f', (now - next_tick) * 1000)
log_ws_flow(now)
if cf_h2_pool is not None:
cf_h2_pool.log_flow(now)
next_tick = time.monotonic() + 5
if now < next_stats:
continue
next_stats = now + 60
log.info("stats: %s", stats.summary())
if cf_h2_pool is not None:
cf_h2_pool.log_stats()
except asyncio.CancelledError:
raise
@@ -563,6 +472,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
except (asyncio.CancelledError, Exception):
pass
serve_task = stop_task = watchdog_task = None
try:
while True:
serve_task = asyncio.create_task(server.serve_forever())
@@ -585,19 +495,12 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
waiters, return_when=asyncio.FIRST_COMPLETED)
if stop_task is not None and stop_task in done:
for task in list(_client_tasks):
task.cancel()
if _client_tasks:
await asyncio.gather(
*_client_tasks, return_exceptions=True)
await _quiet_cancel(watchdog_task)
await _quiet_cancel(serve_task)
server.close()
await server.wait_closed()
break
await _quiet_cancel(watchdog_task)
await _quiet_cancel(serve_task)
if stop_task is not None:
await _quiet_cancel(stop_task)
log.warning(
"Listening socket died, restarting server")
server.close()
@@ -622,6 +525,22 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
log.warning("Server restored, listening on %s:%d",
proxy_config.host, proxy_config.port)
finally:
stopping = True
server.close()
clients = list(_client_tasks)
for task in clients:
task.cancel()
await asyncio.gather(*clients, return_exceptions=True)
for task in (serve_task, watchdog_task, stop_task):
if task is not None:
await _quiet_cancel(task)
await server.wait_closed()
await ws_pool.close()
await cf_worker_pool.close()
if cf_h2_pool is not None:
await cf_h2_pool.close()
cf_h2_pool = None
_server_instance = _server_stop_event = None
log_stats_task.cancel()
try:
await log_stats_task
@@ -663,16 +582,20 @@ def main():
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
help='Socket send/recv buffer size in KB (default 256)')
ap.add_argument('--pool-size', type=int, default=4, metavar='N',
help='WS connection pool size per DC (default 4, min 0)')
help='WS connection pool size per DC (default 4, 0 disables direct WS)')
ap.add_argument('--cfproxy-domain', action='append', default=None,
metavar='DOMAIN',
help='User defined Cloudflare-proxied domain for WS fallback '
help='User defined Cloudflare-proxied domain for H2 media and WS fallback '
'(repeatable for multiple domains)')
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
metavar='DOMAIN',
help='Cloudflare Worker domain for WS fallback '
'(tried before other fallback methods, '
'repeatable for multiple domains)')
h2_flags = ap.add_mutually_exclusive_group()
h2_flags.add_argument('--no-h2', action='store_true',
help='Disable HTTP/2 multiplexing for CF media (enabled by default)')
h2_flags.add_argument('--cfproxy-h2-media', action='store_true', help=argparse.SUPPRESS)
ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback')
ap.add_argument('--no-secure', action='store_true',
@@ -728,6 +651,7 @@ def main():
proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.cfproxy_h2_media = not args.no_h2
proxy_config.disable_secure = args.no_secure
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol
@@ -756,6 +680,8 @@ def main():
root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('httpcore').setLevel(logging.WARNING)
try:
asyncio.run(_run())
+18 -5
View File
@@ -3,7 +3,9 @@ import urllib.request
import http.client
import ssl
import logging
import os
import re
import sys
import certifi
@@ -31,7 +33,8 @@ PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
RESERVED_FIRST_BYTES = {0xEF}
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02',
b'\x4F\x50\x54\x49'}
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
_GITHUB_IPS: Dict[str, str] = {
@@ -58,11 +61,11 @@ 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: bool) -> List[str]:
if dc == 203:
dc = 2
if not is_media:
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
return [f'kws{dc}.web.telegram.org']
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
@@ -140,6 +143,16 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
return super().https_open(req)
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
bundle_dir = getattr(sys, '_MEIPASS', None)
cafile = (os.path.join(bundle_dir, 'certifi', 'cacert.pem')
if bundle_dir else certifi.where())
context = ssl.create_default_context(cafile=cafile)
context.load_default_certs()
context.check_hostname = check_hostname
return context
def build_github_opener() -> urllib.request.OpenerDirector:
context = ssl.create_default_context(cafile=certifi.where())
return urllib.request.build_opener(_PinnedHTTPSHandler(context=context))
return urllib.request.build_opener(
_PinnedHTTPSHandler(context=create_ssl_context()))
+1
View File
@@ -37,6 +37,7 @@ classifiers = [
dependencies = [
"pyperclip==1.9.0",
"certifi",
"httpx[http2]==0.28.1",
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
+156 -13
View File
@@ -1,8 +1,13 @@
import os
import asyncio
import time
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from proxy._aes import Cipher, algorithms, modes
from proxy.bridge import MsgSplitter
from proxy.bridge import MsgSplitter, bridge_ws_reencrypt
from proxy.network_debug import WsActivity, log_ws_flow
from proxy.utils import (
PROTO_ABRIDGED_INT,
PROTO_INTERMEDIATE_INT,
@@ -36,9 +41,10 @@ def _intermediate(payload: bytes) -> bytes:
class MsgSplitterTest(unittest.TestCase):
def _split(self, proto_int, packets, chunk_sizes=None):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, proto_int)
splitter = MsgSplitter(proto_int)
enc = _encryptor(relay_init)
stream = enc.update(b''.join(packets))
plain = b''.join(packets)
stream = enc.update(plain)
chunks = []
if chunk_sizes is None:
@@ -52,8 +58,10 @@ class MsgSplitterTest(unittest.TestCase):
chunks.append(stream[offset:])
parts = []
offset = 0
for chunk in chunks:
parts.extend(splitter.split(chunk))
parts.extend(splitter.split(chunk, plain[offset:offset + len(chunk)]))
offset += len(chunk)
return splitter, stream, parts
def test_abridged_stream_splits_into_packets(self):
@@ -90,27 +98,162 @@ class MsgSplitterTest(unittest.TestCase):
self.assertEqual(len(parts), 8)
def test_empty_chunk_yields_nothing(self):
splitter = MsgSplitter(_relay_init(), PROTO_INTERMEDIATE_INT)
self.assertEqual(splitter.split(b''), [])
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
self.assertEqual(splitter.split(b'', b''), [])
def test_zero_length_packet_disables_splitting(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
stream = enc.update((0).to_bytes(4, 'little') + b'tail')
parts = splitter.split(stream)
plain = (0).to_bytes(4, 'little') + b'tail'
stream = enc.update(plain)
parts = splitter.split(stream, plain)
self.assertEqual(parts, [stream])
self.assertEqual(splitter.split(b'raw'), [b'raw'])
self.assertEqual(splitter.split(b'raw', b'raw'), [b'raw'])
def test_flush_returns_buffered_tail_once(self):
relay_init = _relay_init()
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
enc = _encryptor(relay_init)
partial = enc.update(_intermediate(b'x' * 32)[:10])
self.assertEqual(splitter.split(partial), [])
plain = _intermediate(b'x' * 32)[:10]
partial = enc.update(plain)
self.assertEqual(splitter.split(partial, plain), [])
self.assertEqual(splitter.flush(), [partial])
self.assertEqual(splitter.flush(), [])
def test_extended_and_quick_ack_headers_can_cross_chunk_boundaries(self):
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
(PROTO_INTERMEDIATE_INT, _intermediate),
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
for size in (16, 512, 65536):
packet = bytearray(frame(b'p' * size))
packet[0 if proto == PROTO_ABRIDGED_INT else 3] |= 0x80
packets = [bytes(packet), frame(b't' * 8)]
for header_split in (1, 2, 3):
with self.subTest(proto=proto, size=size, header_split=header_split):
_, stream, parts = self._split(
proto, packets, chunk_sizes=[header_split, 1, 11, size - 8])
self.assertEqual(parts, [stream[:len(packet)], stream[len(packet):]])
class WsReencryptTest(unittest.IsolatedAsyncioTestCase):
async def test_relay_preserves_encrypted_packets_and_incomplete_tail(self):
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
(PROTO_INTERMEDIATE_INT, _intermediate),
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
with self.subTest(proto=proto):
packets = [frame(b'a' * 8), frame(b'b' * 16),
frame(b'c' * 70000), frame(b'd' * 32)[:7]]
client_init, relay_init = _relay_init(), _relay_init()
reader = asyncio.StreamReader()
reader.feed_data(_encryptor(client_init).update(b''.join(packets)))
reader.feed_eof()
sent = []
async def send(data):
sent.append(data)
async def send_batch(parts):
sent.extend(parts)
async def recv():
await asyncio.Future()
ws = SimpleNamespace(send=send, send_batch=send_batch, recv=recv,
close=AsyncMock())
writer = Mock(wait_closed=AsyncMock())
ctx = SimpleNamespace(clt_dec=_encryptor(client_init), tg_enc=_encryptor(relay_init))
await asyncio.wait_for(bridge_ws_reencrypt(
reader, writer, ws, 'framing-test', ctx, splitter=MsgSplitter(proto)), 1)
decoder = _encryptor(relay_init)
self.assertEqual([decoder.update(part) for part in sent], packets)
ws.close.assert_awaited_once()
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
class Cipher:
def update(self, data):
return data
class Socket:
def __init__(self):
self.queue = asyncio.Queue()
self.sending = asyncio.Event()
self.release = asyncio.Event()
self.sent = []
async def send(self, data):
self.sent.append(data)
self.sending.set()
await self.release.wait()
async def recv(self):
return await self.queue.get()
async def close(self):
pass
class Writer:
def __init__(self):
self.data = bytearray()
self.written = asyncio.Event()
self.release = asyncio.Event()
def write(self, data):
self.data.extend(data)
self.written.set()
async def drain(self):
await self.release.wait()
def close(self):
pass
async def wait_closed(self):
pass
ws, writer = Socket(), Writer()
reader = asyncio.StreamReader()
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
try:
reader.feed_data(b'private-native-payload')
await asyncio.wait_for(ws.sending.wait(), 1)
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
self.assertIsNotNone(activity.sending)
self.assertEqual(activity.ws_up, 0)
log_ws_flow(time.monotonic())
# A reply arriving before send drain finishes must not create
# a spurious wait for another response when drain resumes.
await ws.queue.put(b'private-upstream-payload')
await asyncio.wait_for(writer.written.wait(), 1)
self.assertEqual(activity.ws_down, len(writer.data))
self.assertEqual(activity.native_down, 0)
self.assertIsNotNone(activity.writing_native)
log_ws_flow(time.monotonic())
ws.release.set()
writer.release.set()
for _ in range(100):
if activity.native_down and activity.ws_up:
break
await asyncio.sleep(.001)
self.assertEqual(activity.native_down, len(writer.data))
self.assertIsNone(activity.awaiting_rx)
self.assertEqual(ws.sent, [b'private-native-payload'])
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
reader.feed_eof()
await asyncio.wait_for(bridge, 1)
finally:
bridge.cancel()
await asyncio.gather(bridge, return_exceptions=True)
self.assertFalse(WsActivity.active)
output = '\n'.join(captured.output)
self.assertIn('WS FLOW DC203', output)
self.assertIn('WS END DC203', output)
self.assertNotIn('private-', output)
if __name__ == '__main__':
unittest.main()
+1103
View File
File diff suppressed because it is too large. Load diff
+278
View File
@@ -0,0 +1,278 @@
import asyncio
import signal
import socket
import sys
import threading
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import event_loop
async def _wait_for_replacement(loop, old_writer, timeout=1):
async def wait():
while loop._csock is old_writer:
await asyncio.sleep(.005)
await asyncio.wait_for(wait(), timeout)
def _wake_later(loop, callback):
def send():
time.sleep(.03)
loop.call_soon_threadsafe(callback)
thread = threading.Thread(target=send, daemon=True)
thread.start()
return thread
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
class WindowsSelfPipeTest(unittest.TestCase):
def setUp(self):
self.loop = asyncio.ProactorEventLoop()
self.addCleanup(self.loop.close)
def test_eof_replaces_sockets_and_stops_idle_callbacks(self):
loop = self.loop
read = Mock(wraps=loop._loop_self_reading)
loop._loop_self_reading = read
async def run():
# Install after the original callback has armed a read, as can
# happen when the proxy starts in an already running event loop.
await asyncio.sleep(.01)
event_loop.install_self_pipe_guard(loop)
callback = loop._loop_self_reading
event_loop.install_self_pipe_guard(loop)
self.assertIs(loop._loop_self_reading, callback)
old_reader, old_writer = loop._ssock, loop._csock
internal_fds = loop._internal_fds
with patch.object(event_loop.socket, 'socketpair', wraps=socket.socketpair) as pair:
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
self.assertEqual(pair.call_count, 1)
self.assertEqual(old_reader.fileno(), -1)
self.assertEqual(old_writer.fileno(), -1)
self.assertEqual(loop._internal_fds, internal_fds)
self.assertFalse(loop._self_reading_future.done())
read.reset_mock()
await asyncio.sleep(.05)
read.assert_not_called()
completed = loop.create_future()
thread = _wake_later(loop, lambda: completed.set_result(True))
started = time.monotonic()
try:
self.assertTrue(await asyncio.wait_for(completed, 1))
self.assertLess(time.monotonic() - started, .5)
finally:
thread.join(timeout=1)
with self.assertLogs(event_loop.log, 'WARNING') as messages:
loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
self.assertIn('EOF', messages.output[0])
def test_repeated_eof_preserves_existing_tcp_connection(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
finished = loop.create_future()
async def echo(reader, writer):
try:
while True:
data = await reader.read(128)
if not data:
break
writer.write(data)
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
finished.set_result(None)
server = await asyncio.start_server(echo, '127.0.0.1', 0)
writer = None
try:
port = server.sockets[0].getsockname()[1]
reader, writer = await asyncio.open_connection('127.0.0.1', port)
for index in range(5):
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
payload = f'packet {index}'.encode()
writer.write(payload)
await writer.drain()
data = await asyncio.wait_for(reader.readexactly(len(payload)), 1)
self.assertEqual(data, payload)
finally:
if writer is not None:
writer.close()
await writer.wait_closed()
await asyncio.wait_for(finished, 1)
server.close()
await server.wait_closed()
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
def test_reset_rebuilds_but_cancelled_and_stale_callbacks_do_not(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
old_writer = loop._csock
pending = loop._self_reading_future
pending.cancel()
failed = loop.create_future()
failed.set_exception(ConnectionResetError('self-pipe reset'))
loop._self_reading_future = failed
loop._loop_self_reading(failed)
self.assertIsNot(loop._csock, old_writer)
replacement = loop._csock
with patch.object(loop, 'call_exception_handler') as report:
loop._loop_self_reading(failed) # queued callback from the old pipe
loop._loop_self_reading(pending) # cancellation during shutdown
report.assert_not_called()
self.assertIs(loop._csock, replacement)
await asyncio.sleep(.01)
with self.assertLogs(event_loop.log, 'WARNING') as messages:
self.loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
def test_reset_on_read_armed_before_installation_is_recovered(self):
async def run():
loop = self.loop
await asyncio.sleep(.01)
event_loop.install_self_pipe_guard(loop)
old_writer = loop._csock
loop._self_reading_future.set_exception(ConnectionResetError('self-pipe reset'))
await _wait_for_replacement(loop, old_writer)
self.assertFalse(loop._self_reading_future.done())
with self.assertLogs(event_loop.log, 'WARNING') as messages:
self.loop.run_until_complete(run())
self.assertEqual(len(messages.output), 1)
def test_recovery_in_background_proxy_thread(self):
errors = []
async def run():
loop = asyncio.get_running_loop()
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
self.assertFalse(loop._self_reading_future.done())
def worker():
try:
asyncio.run(run())
except BaseException as exc:
errors.append(exc)
with self.assertLogs(event_loop.log, 'WARNING'):
thread = threading.Thread(target=worker, daemon=True)
thread.start()
thread.join(timeout=2)
self.assertFalse(thread.is_alive())
self.assertFalse(errors, errors)
def test_allocation_failure_retries_without_spinning(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
original_pair = socket.socketpair
attempts = 0
def allocate():
nonlocal attempts
attempts += 1
if attempts == 1:
raise OSError('temporarily out of sockets')
return original_pair()
with patch.object(event_loop.socket, 'socketpair', side_effect=allocate):
old_reader, old_writer = loop._ssock, loop._csock
old_writer.shutdown(socket.SHUT_WR)
await asyncio.sleep(.05)
self.assertEqual(attempts, 1)
self.assertIs(loop._csock, old_writer)
self.assertNotEqual(old_reader.fileno(), -1)
self.assertIsNone(loop._self_reading_future)
await _wait_for_replacement(loop, old_writer, timeout=2)
self.assertEqual(attempts, 2)
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
def test_signal_wakeup_fd_is_moved_only_when_owned_by_the_loop(self):
async def run():
loop = self.loop
event_loop.install_self_pipe_guard(loop)
await asyncio.sleep(0)
external_reader, external_writer = socket.socketpair()
external_writer.setblocking(False)
try:
for external in (False, True):
with self.subTest(external=external):
if external:
signal.set_wakeup_fd(external_writer.fileno())
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
fd = signal.set_wakeup_fd(-1)
expected = external_writer if external else loop._csock
self.assertEqual(fd, expected.fileno())
signal.set_wakeup_fd(loop._csock.fileno())
finally:
external_reader.close()
external_writer.close()
with self.assertLogs(event_loop.log, 'WARNING'):
self.loop.run_until_complete(run())
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
class ProxySelfPipeTest(unittest.IsolatedAsyncioTestCase):
async def test_proxy_without_h2_can_stop_from_thread_after_eof(self):
from proxy import tg_ws_proxy
from proxy.config import ProxyConfig
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0, cfproxy_h2_media=False)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()), \
self.assertLogs(event_loop.log, 'WARNING'):
task = asyncio.create_task(tg_ws_proxy._run(stop))
thread = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
old_writer = loop._csock
old_writer.shutdown(socket.SHUT_WR)
await _wait_for_replacement(loop, old_writer)
thread = _wake_later(loop, stop.set)
started = time.monotonic()
await asyncio.wait_for(task, 1)
self.assertLess(time.monotonic() - started, .5)
self.assertIsNone(tg_ws_proxy._server_instance)
finally:
if thread is not None:
thread.join(timeout=1)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if __name__ == '__main__':
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
"""H2 belongs to CF fallback, never to the direct route."""
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge, tg_ws_proxy
from proxy.config import proxy_config
from proxy.utils import PROTO_TAG_INTERMEDIATE
class H2RoutingTest(unittest.IsolatedAsyncioTestCase):
async def test_direct_pool_hit_never_opens_h2(self):
reader = Mock()
writer = Mock()
writer.get_extra_info.return_value = None
writer.wait_closed = AsyncMock()
ws = Mock(send=AsyncMock())
pool = SimpleNamespace(open=AsyncMock())
with patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.51'}), \
patch.object(proxy_config, 'force_test_dc', False), \
patch.object(tg_ws_proxy, 'cf_h2_pool', pool), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
2, True, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=ws)), \
patch.object(tg_ws_proxy, 'bridge_ws_reencrypt', AsyncMock()) as direct, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock()) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
direct.assert_awaited_once()
fallback.assert_not_awaited()
pool.open.assert_not_awaited()
async def test_pool_miss_goes_straight_to_fallback_including_test_dcs(self):
for dc, forced, expected_test in [(2, False, False),
(10002, False, True),
(2, True, True)]:
with self.subTest(dc=dc, forced=forced):
reader = Mock()
writer = Mock(wait_closed=AsyncMock())
writer.get_extra_info.return_value = None
with patch.object(proxy_config, 'force_test_dc', forced), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
dc, False, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=None)) as get, \
patch('proxy.raw_websocket.RawWebSocket.connect', AsyncMock()) as connect, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock(return_value=True)) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
get.assert_awaited_once_with(2, False, is_test_dc=expected_test)
fallback.assert_awaited_once()
self.assertEqual(fallback.call_args.args[4:7], (2, expected_test, False))
connect.assert_not_awaited()
async def test_cf_fallback_order_and_route_guards(self):
cases = [
# media, test DC, CF enabled, Worker succeeds, H2 opens, expected
(True, False, True, False, True, ['h2', 'bridge']),
(True, False, True, False, False, ['h2', 'ws']),
(False, False, True, False, True, ['ws']),
(True, True, True, False, True, ['tcp']),
(True, False, False, False, True, ['tcp']),
(True, False, True, True, True, ['worker']),
]
for media, test_dc, cf, worker, opens, expected in cases:
with self.subTest(expected=expected, media=media, test_dc=test_dc, cf=cf):
calls = []
async def open_h2(*args):
calls.append('h2')
return object() if opens else None
def route(name):
async def run(*args, **kwargs):
calls.append(name)
return True
return run
pool = SimpleNamespace(open=AsyncMock(side_effect=open_h2))
with patch.object(proxy_config, 'fallback_cfproxy', cf), \
patch.object(proxy_config, 'cfproxy_worker_domains',
['worker.example'] if worker else []), \
patch.object(bridge, 'bridge_h2', side_effect=route('bridge')), \
patch.object(bridge, '_cfproxy_fallback', side_effect=route('ws')), \
patch.object(bridge, '_cfproxy_worker_fallback', side_effect=route('worker')), \
patch.object(bridge, '_tcp_fallback', side_effect=route('tcp')):
result = await bridge.do_fallback(
Mock(), Mock(), b'', 'test', 2, test_dc, media, '', Mock(),
h2_pool=pool, proto_tag=PROTO_TAG_INTERMEDIATE)
self.assertTrue(result)
self.assertEqual(calls, expected)
+127
View File
@@ -0,0 +1,127 @@
"""Default/CLI/GUI configuration must select the same effective H2 route."""
import asyncio
import contextlib
import io
import json
import logging
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from proxy.config import ProxyConfig
from proxy import tg_ws_proxy
from utils import tray_common
from utils.default_config import default_tray_config
from ui.settings import prepare_settings
class H2SettingsTest(unittest.TestCase):
def test_cli_default_opt_out_and_existing_launch_script(self):
cases = [([], True), (['--no-h2'], False),
(['--cfproxy-h2-media'], True), (['--no-cfproxy'], False),
(['--no-secure'], False), (['--force-test-dc'], False)]
for flags, enabled in cases:
with self.subTest(flags=flags):
config = ProxyConfig()
observed = []
async def run():
observed.append(config.h2_enabled)
root = logging.getLogger()
previous_handlers, previous_level = root.handlers[:], root.level
try:
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, '_run', run), \
patch('sys.argv', ['proxy', '--secret', 'ab' * 16] + flags):
tg_ws_proxy.main()
self.assertEqual(observed, [enabled])
finally:
for handler in root.handlers[:]:
if handler not in previous_handlers:
root.removeHandler(handler)
handler.close()
root.setLevel(previous_level)
def test_help_advertises_opt_out_only(self):
output = io.StringIO()
with patch('sys.argv', ['proxy', '--help']), contextlib.redirect_stdout(output):
with self.assertRaises(SystemExit) as raised:
tg_ws_proxy.main()
self.assertEqual(raised.exception.code, 0)
self.assertIn('--no-h2', output.getvalue())
self.assertNotIn('--cfproxy-h2-media', output.getvalue())
def test_saved_setting_migration_and_runtime_restart(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'config.json'
path.write_text(json.dumps({'port': 1444}), encoding='utf-8')
with patch.object(tray_common, 'CONFIG_FILE', path), \
patch.object(tray_common, 'ensure_dirs'), \
patch.object(tray_common, '_apply_ui_language'):
config = tray_common.load_config()
self.assertTrue(config['h2'])
change = prepare_settings(config, {'h2': False}, default_tray_config())
self.assertTrue(change.requires_restart)
tray_common.save_config(change.config)
saved = tray_common.load_config()
self.assertFalse(saved['h2'])
runtime = ProxyConfig()
with patch.object(tray_common, 'proxy_config', runtime):
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertFalse(runtime.h2_enabled)
saved['h2'] = True
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertTrue(runtime.h2_enabled)
for key, value in [('cfproxy', False), ('no_secure', True),
('force_test_dc', True)]:
self.assertTrue(tray_common.apply_proxy_config({**saved, key: value}))
self.assertFalse(runtime.h2_enabled)
self.assertTrue(runtime.cfproxy_h2_media)
class H2ServerLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def test_stop_and_cancellation_close_default_h2_and_listener_tasks(self):
for cancel in (False, True):
with self.subTest(cancel=cancel):
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0)
stop = asyncio.Event()
before = asyncio.all_tasks()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()):
server = asyncio.create_task(tg_ws_proxy._run(stop))
writer = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
pool = tg_ws_proxy.cf_h2_pool
self.assertIsNotNone(pool)
port = tg_ws_proxy._server_instance.sockets[0].getsockname()[1]
_, writer = await asyncio.open_connection('127.0.0.1', port)
if cancel:
server.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(server, 2)
else:
stop.set()
await asyncio.wait_for(server, 2)
self.assertTrue(pool.closed)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
self.assertIsNone(tg_ws_proxy._server_instance)
self.assertFalse(tg_ws_proxy._client_tasks)
self.assertFalse(asyncio.all_tasks() - before)
finally:
server.cancel()
await asyncio.gather(server, return_exceptions=True)
if writer is not None:
writer.close()
await writer.wait_closed()
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
import json
import unittest
from pathlib import Path
LOCALES_DIR = Path(__file__).parents[1] / "ui" / "i18n"
# Values that are intentionally identical across locales
# (brand names, technical terms, format-only strings).
# Note: settings.language is deliberately "Language" in ru.json —
# upstream keeps the language selector label in English on purpose.
ALLOWED_UNTRANSLATED = frozenset({
"app.name",
"button.test_loading",
"connectivity.cfworker_title",
"connectivity.error_line",
"first_run.manual_mtproto",
"language.en",
"language.ru",
"section.cfproxy",
"section.cfworker",
"settings.language",
})
def _load(code: str) -> dict:
with open(LOCALES_DIR / f"{code}.json", encoding="utf-8") as f:
return json.load(f)
class I18nParityTest(unittest.TestCase):
def test_en_ru_key_sets_match(self):
en = _load("en")
ru = _load("ru")
self.assertEqual(
set(en), set(ru),
f"Missing in ru: {sorted(set(en) - set(ru))}; "
f"missing in en: {sorted(set(ru) - set(en))}",
)
def test_ru_has_no_untouched_english_leftovers(self):
en = _load("en")
ru = _load("ru")
leftovers = sorted(
key for key in set(en) & set(ru)
if en[key] == ru[key] and key not in ALLOWED_UNTRANSLATED
)
self.assertEqual(leftovers, [])
def test_format_placeholders_match(self):
import string
en = _load("en")
ru = _load("ru")
for key in set(en) & set(ru):
en_fields = {
name for _, name, _, _ in string.Formatter().parse(en[key])
if name
}
ru_fields = {
name for _, name, _, _ in string.Formatter().parse(ru[key])
if name
}
self.assertEqual(
en_fields, ru_fields,
f"Placeholder mismatch for key {key!r}",
)
if __name__ == "__main__":
unittest.main()
+405
View File
@@ -0,0 +1,405 @@
import hashlib
import io
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from contextlib import ExitStack
from http.client import IncompleteRead
from pathlib import Path
from unittest import mock
from utils import linux_update, update_check
class _Proc:
def __init__(self, returncode, stdout=b'', stderr=b''):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class AssetNameTest(unittest.TestCase):
def test_names_match_release_assets(self):
self.assertEqual(linux_update.asset_name('binary'), 'TgWsProxy_linux_amd64')
self.assertEqual(linux_update.asset_name('deb'), 'TgWsProxy_linux_amd64.deb')
self.assertEqual(linux_update.asset_name('rpm'), 'TgWsProxy_linux_amd64.rpm')
class DetectInstallKindTest(unittest.TestCase):
def _detect(self, *, machine='x86_64', tools=(), owned_by=None, writable=True):
exe = Path('/usr/bin/tg-ws-proxy')
def _which(name):
return '/usr/bin/' + name if name in tools else None
def _run_quiet(cmd):
return cmd[0] == owned_by
with mock.patch.object(linux_update.platform, 'machine', return_value=machine), \
mock.patch.object(linux_update.shutil, 'which', side_effect=_which), \
mock.patch.object(linux_update, '_run_quiet', side_effect=_run_quiet), \
mock.patch.object(linux_update.os, 'access', return_value=writable), \
mock.patch.object(linux_update, '_is_root', return_value=False):
return linux_update.detect_install_kind(exe)
def test_deb_package_with_pkexec(self):
self.assertEqual(
self._detect(tools=('dpkg-query', 'pkexec', 'apt-get'), owned_by='dpkg-query'),
'deb',
)
def test_rpm_package_with_pkexec(self):
self.assertEqual(
self._detect(tools=('rpm', 'pkexec'), owned_by='rpm'), 'rpm',
)
def test_package_without_pkexec_is_unsupported(self):
self.assertIsNone(self._detect(tools=('dpkg-query',), owned_by='dpkg-query'))
def test_aur_install_is_unsupported(self):
self.assertIsNone(
self._detect(tools=('pacman', 'pkexec'), owned_by='pacman'),
)
def test_standalone_writable_binary(self):
self.assertEqual(self._detect(tools=()), 'binary')
def test_read_only_dir_is_supported_via_pkexec(self):
self.assertEqual(self._detect(tools=('pkexec',), writable=False), 'binary')
def test_read_only_dir_without_pkexec_is_unsupported(self):
self.assertIsNone(self._detect(tools=(), writable=False))
def test_non_amd64_is_unsupported(self):
self.assertIsNone(self._detect(machine='aarch64'))
class InstallCmdTest(unittest.TestCase):
package_path = Path(tempfile.gettempdir()) / 'pkg'
def _cmd(self, kind, tools):
with mock.patch.object(
linux_update.shutil, 'which',
side_effect=lambda n: '/usr/bin/' + n if n in tools else None,
), mock.patch.object(linux_update, '_is_root', return_value=False):
return linux_update.install_cmd(kind, self.package_path)
def test_deb_prefers_apt_get_and_elevates(self):
cmd = self._cmd('deb', ('apt-get',))
self.assertEqual(cmd[:3], ['pkexec', 'apt-get', 'install'])
self.assertEqual(cmd[-1], str(self.package_path))
def test_deb_falls_back_to_dpkg(self):
self.assertEqual(self._cmd('deb', ()), ['pkexec', 'dpkg', '-i', str(self.package_path)])
def test_rpm_prefers_dnf_without_removing_packages(self):
cmd = self._cmd('rpm', ('dnf',))
self.assertEqual(cmd, ['pkexec', 'dnf', 'install', '-y', str(self.package_path)])
self.assertNotIn('--allowerasing', cmd)
def test_rpm_falls_back_to_rpm_binary(self):
self.assertEqual(
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)],
)
def test_unknown_kind_is_rejected(self):
with self.assertRaises(ValueError):
self._cmd('binary', ('apt-get',))
def test_root_does_not_use_pkexec(self):
with mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
self.assertEqual(
linux_update.install_cmd('deb', self.package_path),
['dpkg', '-i', str(self.package_path)],
)
class ApplyUpdateTest(unittest.TestCase):
def _download(self, payload=b'new'):
def _fn(url, dest_dir, suffix='.tmp', digest=''):
path = Path(dest_dir) / ('new' + suffix)
path.write_bytes(payload)
return path
return _fn
def test_writable_binary_is_replaced_in_place(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', self._download()):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
@unittest.skipUnless(sys.platform == 'linux', 'Linux executable permissions')
def test_replaced_binary_has_executable_permissions(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', self._download()):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
def test_read_only_dir_binary_is_installed_with_elevation(self):
exe = Path('/usr/local/bin/TgWsProxy')
with tempfile.TemporaryDirectory() as tmp:
def _download(url, dest_dir, suffix='.tmp', digest=''):
path = Path(tmp) / ('new' + suffix)
path.write_bytes(b'new')
return path
run = mock.Mock(return_value=_Proc(0))
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(linux_update.os, 'access', return_value=False), \
mock.patch.object(linux_update, '_is_root', return_value=False), \
mock.patch('utils.linux_update.download_asset', _download), \
mock.patch.object(linux_update.subprocess, 'run', run):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
cmd = run.call_args[0][0]
self.assertEqual(cmd[:3], ['pkexec', 'sh', '-c'])
self.assertEqual(cmd[-1], str(exe))
def test_deb_install_failure_returns_stderr(self):
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error'))
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
mock.patch('utils.linux_update.download_asset', self._download()), \
mock.patch.object(linux_update.subprocess, 'run', run), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
err = linux_update.apply_update('deb', 'https://x.invalid/a.deb')
self.assertEqual(err, 'dpkg: error')
def test_deb_install_success_removes_downloaded_package(self):
holder = {}
def _download(url, dest_dir, suffix='.tmp', digest=''):
path = Path(dest_dir) / ('pkg' + suffix)
path.write_bytes(b'deb')
holder['path'] = path
return path
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
mock.patch('utils.linux_update.download_asset', _download), \
mock.patch.object(linux_update.subprocess, 'run',
return_value=_Proc(0)), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True):
self.assertIsNone(linux_update.apply_update('deb', 'https://x.invalid/a.deb'))
self.assertFalse(holder['path'].exists())
def test_digest_is_passed_to_the_downloader(self):
seen = {}
def _download(url, dest_dir, suffix='.tmp', digest=''):
seen['digest'] = digest
path = Path(dest_dir) / ('new' + suffix)
path.write_bytes(b'new')
return path
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch('utils.linux_update.download_asset', _download):
linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
self.assertEqual(seen['digest'], 'sha256:ab')
def test_download_failure_is_reported(self):
for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
with self.subTest(error=error), \
mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
mock.patch.object(linux_update, 'download_asset', side_effect=error), \
mock.patch.object(linux_update, '_run_install') as install:
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error))
install.assert_not_called()
def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self):
for kind in ('binary', 'deb', 'rpm'):
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
opener = mock.Mock()
opener.open.return_value = io.BytesIO(b'corrupt')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(update_check, 'build_github_opener', return_value=opener), \
mock.patch.object(linux_update, '_run_install') as install:
error = linux_update.apply_update(kind, 'https://example.invalid/asset',
digest='sha256:' + '0' * 64)
self.assertIn('SHA-256', error)
install.assert_not_called()
self.assertEqual(exe.read_bytes(), b'old')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
def test_verified_download_is_installed(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old')
opener = mock.Mock()
opener.open.return_value = io.BytesIO(b'new')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
mock.patch.object(update_check, 'build_github_opener', return_value=opener):
self.assertIsNone(linux_update.apply_update(
'binary', 'https://example.invalid/asset',
digest='sha256:' + hashlib.sha256(b'new').hexdigest(),
))
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
def test_install_timeout_cleans_download_directory(self):
holder = {}
def download(url, dest_dir, **kwargs):
holder['directory'] = dest_dir
path = dest_dir / 'update.deb'
path.write_bytes(b'package')
return path
with mock.patch.object(linux_update, 'download_asset', side_effect=download), \
mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)):
self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset'))
self.assertFalse(holder['directory'].exists())
@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement')
class ElevatedBinaryReplacementTest(unittest.TestCase):
def test_replaces_a_running_executable_with_special_characters_in_its_path(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
exe = directory / 'app $(touch unwanted); тест'
src = directory / 'download with spaces'
shutil.copy2(shutil.which('sleep'), exe)
src.write_bytes(b'new')
process = subprocess.Popen([str(exe), '30'])
try:
with mock.patch.object(linux_update, '_is_root', return_value=True):
cmd = linux_update.replace_binary_cmd(src, exe)
self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0)
self.assertIsNone(process.poll())
self.assertEqual(exe.read_bytes(), b'new')
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
self.assertEqual(set(directory.iterdir()), {exe, src})
finally:
process.terminate()
process.wait(timeout=5)
def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self):
with tempfile.TemporaryDirectory() as tmp:
exe = Path(tmp) / 'app'
exe.write_bytes(b'old')
with mock.patch.object(linux_update, '_is_root', return_value=True):
cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe)
result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(exe.read_bytes(), b'old')
self.assertEqual(list(Path(tmp).iterdir()), [exe])
class ExternalCommandsEnvTest(unittest.TestCase):
def test_commands_use_system_libraries_without_changing_parent_environment(self):
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
probe = (
'import os, sys; '
'assert os.environ.get("LD_LIBRARY_PATH", "") == sys.argv[1]; '
'assert "_PYI_APPLICATION_HOME_DIR" not in os.environ'
)
for original in ('/usr/lib', None):
for runner, expected in ((linux_update._run_quiet, True),
(linux_update._run_install, None)):
with self.subTest(original=original, runner=runner.__name__):
env = dict(os.environ)
env.update(LD_LIBRARY_PATH=bundle, _PYI_APPLICATION_HOME_DIR=bundle)
env.pop('LD_LIBRARY_PATH_ORIG', None)
if original is not None:
env['LD_LIBRARY_PATH_ORIG'] = original
with mock.patch.dict(os.environ, env, clear=True), \
mock.patch.object(sys, '_MEIPASS', bundle, create=True):
self.assertEqual(
runner([sys.executable, '-c', probe, original or '']), expected,
)
self.assertEqual(os.environ['LD_LIBRARY_PATH'], bundle)
self.assertEqual(os.environ['_PYI_APPLICATION_HOME_DIR'], bundle)
class RelaunchEnvTest(unittest.TestCase):
def _env(self, env, meipass='/tmp/_MEI1'):
with mock.patch.dict(linux_update.os.environ, env, clear=True), \
mock.patch.object(linux_update.sys, '_MEIPASS', meipass, create=True):
return linux_update.relaunch_env()
def test_bundle_paths_are_dropped_and_originals_restored(self):
out = self._env({
'_PYI_APPLICATION_HOME_DIR': '/tmp/_MEI1',
'_MEIPASS': '/tmp/_MEI1',
'LD_LIBRARY_PATH': '/tmp/_MEI1',
'LD_LIBRARY_PATH_ORIG': '/usr/lib',
'GI_TYPELIB_PATH': '/tmp/_MEI1',
'PATH': os.pathsep.join(('/tmp/_MEI1', '/usr/bin')),
'HOME': '/home/u',
})
self.assertEqual(out['LD_LIBRARY_PATH'], '/usr/lib')
self.assertNotIn('GI_TYPELIB_PATH', out)
self.assertEqual(out['PATH'], '/usr/bin')
self.assertNotIn('_MEIPASS', out)
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', out)
self.assertEqual(out['HOME'], '/home/u')
def test_environment_without_bundle_is_unchanged(self):
env = {'PATH': '/usr/bin', 'HOME': '/home/u'}
with mock.patch.dict(linux_update.os.environ, env, clear=True):
with mock.patch.object(linux_update.sys, '_MEIPASS', None, create=True):
out = linux_update.relaunch_env()
self.assertEqual(out, env)
class RestartAfterUpdateTest(unittest.TestCase):
def test_restart_releases_resources_before_launch_and_preserves_arguments(self):
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
import linux
events = []
exe = str(Path(tempfile.gettempdir()) / 'proxy')
with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \
mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \
mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \
mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \
mock.patch.object(sys, 'argv', ['proxy', '--portable']), \
mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \
mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')):
linux._restart_after_update()
self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit'])
self.assertEqual(launch.call_args[0][0], [exe, '--portable'])
self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'})
def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self):
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
import linux
with ExitStack() as stack:
mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in (
'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error',
)}
exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit'))
stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed')))
linux._restart_after_update()
mocks['acquire_lock'].assert_called_once_with()
mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error'])
mocks['_show_error'].assert_called_once()
exit_process.assert_not_called()
if __name__ == '__main__':
unittest.main()
+228 -10
View File
@@ -1,3 +1,4 @@
import asyncio
import time
import unittest
@@ -6,7 +7,9 @@ from types import SimpleNamespace
from unittest import mock
from proxy.config import proxy_config
from proxy.pool import _WsPool
from proxy.pool import _CfWorkerPool, _WsPool
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
from proxy.utils import ws_domains
class _StopRotation(Exception):
@@ -15,25 +18,39 @@ class _StopRotation(Exception):
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = SimpleNamespace(transport=transport)
return SimpleNamespace(_closed=False, writer=writer)
writer = mock.Mock(transport=transport, drain=mock.AsyncMock(),
wait_closed=mock.AsyncMock())
return RawWebSocket(asyncio.StreamReader(), writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_warmup_preserves_media_ws_with_or_without_h2(self):
for cf, secure, opted_out, media in [(True, True, False, True),
(False, True, False, True),
(True, False, False, True),
(True, True, True, True)]:
with self.subTest(cf=cf, secure=secure, opted_out=opted_out), \
mock.patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.220'}), \
mock.patch.object(proxy_config, 'fallback_cfproxy', cf), \
mock.patch.object(proxy_config, 'disable_secure', not secure), \
mock.patch.object(proxy_config, 'cfproxy_h2_media', not opted_out), \
mock.patch.object(proxy_config, 'force_test_dc', False):
pool = _WsPool()
with mock.patch.object(pool, '_schedule_refill') as refill:
await pool.warmup()
keys = [call.args[0] for call in refill.call_args_list]
self.assertIn((2, False, False), keys)
self.assertEqual((2, True, False) in keys, media)
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False)
key = (2, False, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
sleep_calls = 0
async def stop_after_one_iteration(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls > 1:
raise _StopRotation
raise _StopRotation
with mock.patch.object(proxy_config, 'pool_size', 4):
with mock.patch(
@@ -49,5 +66,206 @@ class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
key, '149.154.167.220', ['example.com'])
class WsPoolTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.pool = _WsPool()
self.key = (2, False, False)
self.pool.WS_POOL_CHECK_INTERVAL = .01
for name, value in [('dc_redirects', {2: '192.0.2.1'}),
('pool_size', 1), ('force_test_dc', False)]:
patcher = mock.patch.object(proxy_config, name, value)
patcher.start()
self.addCleanup(patcher.stop)
self.addAsyncCleanup(self.pool.close)
async def wait_for(self, predicate):
async def wait():
while not predicate():
await asyncio.sleep(.001)
await asyncio.wait_for(wait(), 1)
async def test_regular_sessions_never_use_media_domain(self):
for dc in (2, 4):
with self.subTest(dc=dc), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=WsHandshakeError(302, 'redirect')) as connect:
self.assertIsNone(await self.pool._connect_one('192.0.2.1', ws_domains(dc, False)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
[f'kws{dc}.web.telegram.org'] * 2)
async def test_media_tries_regular_domain_after_both_modes_fail(self):
for error in (asyncio.TimeoutError(), ConnectionResetError(),
WsHandshakeError(302, 'redirect'), WsHandshakeError(500, 'error')):
with self.subTest(error=type(error)), mock.patch(
'proxy.pool.RawWebSocket.connect',
side_effect=[error, error, _open_ws()]) as connect:
self.assertIsNotNone(await self.pool._connect_one('192.0.2.1', ws_domains(2, True)))
self.assertEqual([call.args[1] for call in connect.call_args_list],
['kws2-1.web.telegram.org'] * 2 + ['kws2.web.telegram.org'])
async def test_concurrent_preference_change_does_not_skip_fronting(self):
ws = _open_ws()
async def connect(*args, **kwargs):
if kwargs['sni'] is None:
self.pool.try_fronting_first = True
raise asyncio.TimeoutError()
return ws
with mock.patch('proxy.pool.RawWebSocket.connect', side_effect=connect) as dial:
self.assertIs(await self.pool._connect_one('192.0.2.1', ws_domains(2, False)), ws)
self.assertEqual(dial.await_count, 2)
async def test_miss_returns_without_waiting_and_starts_only_one_refill(self):
started = asyncio.Event()
async def connect(*args):
started.set()
await asyncio.Future()
with mock.patch.object(self.pool, '_connect_one', side_effect=connect) as dial:
for _ in range(10):
self.assertIsNone(await self.pool.get(2, False))
await asyncio.wait_for(started.wait(), 1)
self.assertEqual(dial.await_count, 1)
await self.pool.close()
async def test_empty_pool_recovers_in_background_after_backoff(self):
ws = _open_ws()
self.pool.REFILL_BACKOFF_INITIAL = .02
with mock.patch.object(self.pool, '_connect_one',
side_effect=[None, ws]) as dial:
self.assertIsNone(await self.pool.get(2, False))
await self.wait_for(lambda: self.key in self.pool._refill_after)
self.assertEqual(dial.await_count, 1)
# No new client requests are needed to resume refilling.
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertEqual(dial.await_count, 2)
self.assertNotIn(self.key, self.pool._refill_failures)
await self.pool.close()
async def test_ready_connection_is_available_before_slower_attempt(self):
ready = _open_ws()
slow_started = asyncio.Event()
calls = 0
async def connect(*args):
nonlocal calls
calls += 1
if calls == 1:
slow_started.set()
await asyncio.Future()
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
await self.pool.get(2, False)
await asyncio.wait_for(slow_started.wait(), 1)
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
self.assertIs(await self.pool.get(2, False), ready)
await self.pool.close()
async def test_stale_connections_are_skipped(self):
for state in ('expired', 'eof', 'exception', 'closed', 'closing'):
with self.subTest(state=state):
stale, good = _open_ws(), _open_ws()
created = time.monotonic()
if state == 'expired':
created -= self.pool.WS_POOL_MAX_AGE
elif state == 'eof':
stale.reader.feed_eof()
elif state == 'exception':
stale.reader.set_exception(ConnectionResetError())
elif state == 'closed':
stale._closed = True
else:
stale.writer.transport.is_closing = lambda: True
self.pool._idle[self.key] = deque([(stale, created), (good, time.monotonic())])
with mock.patch.object(self.pool, '_schedule_refill'):
self.assertIs(await self.pool.get(2, False), good)
await asyncio.sleep(0)
async def test_hit_does_not_reset_failed_refill_backoff(self):
self.pool._idle[self.key] = deque([(_open_ws(), time.monotonic())])
self.pool._refill_failures[self.key] = 3
self.pool._refill_after[self.key] = time.monotonic() + 60
with mock.patch.object(self.pool, '_schedule_rotation'):
self.assertIsNotNone(await self.pool.get(2, False))
self.assertEqual(self.pool._refill_failures[self.key], 3)
self.assertFalse(self.pool._refilling)
async def test_zero_size_and_unconfigured_dc_do_not_connect(self):
with mock.patch.object(self.pool, '_connect_one') as dial:
self.assertIsNone(await self.pool.get(4, False))
with mock.patch.object(proxy_config, 'pool_size', 0):
await self.pool.warmup()
self.assertIsNone(await self.pool.get(2, False))
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
dial.assert_not_called()
async def test_test_dc_has_separate_pool_and_path(self):
prod = _open_ws()
self.pool._idle[self.key] = deque([(prod, time.monotonic())])
with mock.patch.object(self.pool, '_connect_one', return_value=_open_ws()) as dial:
self.assertIsNone(await self.pool.get(2, False, is_test_dc=True))
test_key = (2, False, True)
await self.wait_for(lambda: bool(self.pool._idle.get(test_key)))
dial.assert_awaited_once_with('192.0.2.1', ['kws2.web.telegram.org'], '/apiws_test')
self.assertIs(self.pool._idle[self.key][0][0], prod)
await self.pool.close()
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
ready = _open_ws()
idle = _open_ws()
started = asyncio.Event()
self.pool._idle[self.key] = deque([(idle, time.monotonic())])
async def connect(*args):
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
# Completion raced with shutdown; it must not leak into the pool.
return ready
with mock.patch.object(proxy_config, 'pool_size', 2), \
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
self.pool._schedule_refill(self.key, '192.0.2.1', ws_domains(2, False))
await asyncio.wait_for(started.wait(), 1)
await self.pool.close()
self.assertFalse(self.pool._idle)
self.assertFalse(self.pool._rotating)
self.assertFalse(self.pool._refilling)
ready.writer.close.assert_called_once()
idle.writer.close.assert_called_once()
class CfWorkerPoolTest(unittest.IsolatedAsyncioTestCase):
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
pool = _CfWorkerPool()
ready = _open_ws()
idle = _open_ws()
started = asyncio.Event()
pool._idle[2] = deque([(idle, time.monotonic(), 'a.example')])
async def connect(*args):
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
return ready, 'b.example'
with mock.patch.object(proxy_config, 'pool_size', 1), \
mock.patch.object(pool, '_connect_one', side_effect=connect):
pool._schedule_refill(4, '192.0.2.4', ['b.example'])
await asyncio.wait_for(started.wait(), 1)
await pool.close()
self.assertFalse(pool._idle)
self.assertFalse(pool._refilling)
ready.writer.close.assert_called_once()
idle.writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+58
View File
@@ -1,5 +1,6 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
@@ -35,6 +36,15 @@ def _recv(chunks, cls=RawWebSocket):
class XorMaskTest(unittest.TestCase):
def test_matches_bytewise_xor_at_fast_path_and_frame_boundaries(self):
for length in (0, 1, 2, 3, 4, 5, 125, 126, 127, 255, 511, 512, 513, 514, 515,
16384, 65535, 65536, 65537, 1024 * 1024 + 3):
data = (bytes(range(256)) * (length // 256 + 1))[:length]
for mask in (b'\x00' * 4, b'\xff' * 4, b'\x01\x7f\x80\xff'):
with self.subTest(length=length, mask=mask):
expected = bytes(value ^ mask[index % 4] for index, value in enumerate(data))
self.assertEqual(_xor_mask(data, mask), expected)
def test_roundtrip(self):
data = bytes(range(256)) * 3
mask = b'\x01\x02\x03\x04'
@@ -45,6 +55,21 @@ class XorMaskTest(unittest.TestCase):
class BuildFrameTest(unittest.TestCase):
def test_masked_frame_wire_format_across_all_length_encodings(self):
mask = b'\x01\x7f\x80\xff'
for length in (1, 125, 126, 511, 512, 513, 65535, 65536, 65537):
payload = (bytes(range(256)) * (length // 256 + 1))[:length]
with self.subTest(length=length), patch('proxy.raw_websocket.os.urandom', return_value=mask):
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, payload, mask=True)
if length < 126:
header = bytes([0x82, 0x80 | length])
elif length < 65536:
header = b'\x82\xfe' + length.to_bytes(2, 'big')
else:
header = b'\x82\xff' + length.to_bytes(8, 'big')
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
self.assertEqual(frame, header + mask + masked)
def test_short_unmasked_frame(self):
self.assertEqual(
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
@@ -126,5 +151,38 @@ class HandshakeErrorTest(unittest.TestCase):
self.assertFalse(WsHandshakeError(code, '').is_redirect)
class HandshakeCleanupTest(unittest.IsolatedAsyncioTestCase):
async def test_cancelling_pending_upgrade_closes_socket(self):
reader = asyncio.StreamReader()
writer = Mock(drain=AsyncMock())
started = asyncio.Event()
async def drain():
started.set()
writer.drain.side_effect = drain
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
task = asyncio.create_task(RawWebSocket.connect('192.0.2.1', 'example.org'))
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
writer.close.assert_called_once()
async def test_failed_upgrade_closes_socket(self):
for error in (ConnectionResetError(), asyncio.TimeoutError()):
with self.subTest(error=type(error)):
reader = Mock(readline=AsyncMock(side_effect=error))
writer = Mock(drain=AsyncMock())
with patch('proxy.raw_websocket.asyncio.open_connection',
AsyncMock(return_value=(reader, writer))), \
patch('proxy.raw_websocket.set_sock_opts'):
with self.assertRaises(type(error)):
await RawWebSocket.connect('192.0.2.1', 'example.org')
writer.close.assert_called_once()
if __name__ == '__main__':
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import time
import unittest
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge
class TcpFallbackTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
bridge.reset_tcp_backoff()
self.addCleanup(bridge.reset_tcp_backoff)
self.key = ('192.0.2.1', 443)
self.remote = Mock(drain=AsyncMock(), wait_closed=AsyncMock())
async def fallback(self, dst='192.0.2.1', port=443):
return await bridge._tcp_fallback(
Mock(), Mock(), dst, port, b'init', 'test', Mock())
async def test_failure_backoff_doubles_caps_and_skips_new_connections(self):
with patch.object(bridge.asyncio, 'open_connection',
AsyncMock(side_effect=asyncio.TimeoutError())) as connect:
for delay in (30, 60, 120, 240, 480, 960, 1920, 3600, 3600):
bridge._tcp_retry_after[self.key] = 0
start = time.monotonic()
self.assertFalse(await self.fallback())
remaining = bridge._tcp_retry_after[self.key] - start
self.assertGreaterEqual(remaining, delay - .001)
self.assertLess(remaining, delay + 1)
count = connect.await_count
self.assertFalse(await self.fallback())
self.assertEqual(connect.await_count, count)
self.assertFalse(bridge._tcp_connecting)
async def test_other_ips_and_ports_are_not_blocked(self):
bridge._tcp_retry_after[self.key] = time.monotonic() + 60
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback('192.0.2.2'))
self.assertTrue(await self.fallback(port=80))
self.assertEqual(connect.await_count, 2)
async def test_success_clears_backoff_and_allows_following_sessions(self):
bridge._tcp_failures[self.key] = 5
bridge._tcp_retry_after[self.key] = 0
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))) as connect, \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
self.assertTrue(await self.fallback())
self.assertNotIn(self.key, bridge._tcp_failures)
self.assertNotIn(self.key, bridge._tcp_retry_after)
self.assertTrue(await self.fallback())
self.assertEqual(connect.await_count, 2)
async def test_concurrent_requests_only_start_one_connection_attempt(self):
started = asyncio.Event()
release = asyncio.Event()
async def connect(*args):
started.set()
await release.wait()
raise ConnectionRefusedError()
with patch.object(bridge.asyncio, 'open_connection', side_effect=connect) as dial:
first = asyncio.create_task(self.fallback())
try:
await asyncio.wait_for(started.wait(), 1)
results = await asyncio.gather(*(self.fallback() for _ in range(8)))
self.assertEqual(results, [False] * 8)
self.assertEqual(dial.await_count, 1)
release.set()
self.assertFalse(await first)
finally:
first.cancel()
await asyncio.gather(first, return_exceptions=True)
async def test_init_failure_closes_socket_and_enters_backoff(self):
self.remote.drain.side_effect = ConnectionResetError()
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))), \
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()) as forward:
self.assertFalse(await self.fallback())
self.remote.close.assert_called_once()
forward.assert_not_awaited()
self.assertIn(self.key, bridge._tcp_retry_after)
async def test_cancellation_releases_attempt_without_marking_ip_blocked(self):
started = asyncio.Event()
async def drain():
started.set()
await asyncio.Future()
self.remote.drain.side_effect = drain
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
return_value=(Mock(), self.remote))):
task = asyncio.create_task(self.fallback())
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.remote.close.assert_called_once()
self.assertFalse(bridge._tcp_connecting)
self.assertFalse(bridge._tcp_failures)
if __name__ == '__main__':
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
import unittest
from unittest import mock
from utils import tray_common
class CheckUpdateAsyncTest(unittest.TestCase):
def test_disabled_check_does_not_start_a_thread(self):
called = []
with mock.patch.object(tray_common.threading, 'Thread') as thread:
tray_common.check_update_async(
{'check_updates': False}, lambda: False, lambda v, u: called.append(v),
)
thread.assert_not_called()
self.assertEqual(called, [])
def test_update_is_reported_to_the_handler(self):
status = {'has_update': True, 'latest': '2.0.0', 'html_url': 'https://x.invalid/r'}
seen = []
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
mock.patch.object(tray_common.time, 'sleep'), \
mock.patch('utils.update_check.run_check'), \
mock.patch('utils.update_check.get_status', return_value=status):
tray_common.check_update_async(
{'check_updates': True}, lambda: False,
lambda version, url: seen.append((version, url)),
)
thread.call_args.kwargs['target']()
self.assertEqual(seen, [('2.0.0', 'https://x.invalid/r')])
def test_no_update_means_no_handler_call(self):
seen = []
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
mock.patch.object(tray_common.time, 'sleep'), \
mock.patch('utils.update_check.run_check'), \
mock.patch('utils.update_check.get_status',
return_value={'has_update': False}):
tray_common.check_update_async(
{'check_updates': True}, lambda: False,
lambda version, url: seen.append(version),
)
thread.call_args.kwargs['target']()
self.assertEqual(seen, [])
if __name__ == '__main__':
unittest.main()
+267
View File
@@ -1,7 +1,22 @@
import hashlib
import io
import json
import os
import sys
import tempfile
import threading
import unittest
from http.client import IncompleteRead
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError, URLError
from utils import update_check
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
PAYLOAD = b'payload'
DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest()
class ParseVersionTupleTest(unittest.TestCase):
def test_plain_and_prefixed_versions(self):
@@ -66,5 +81,257 @@ class ExtractAssetsTest(unittest.TestCase):
self.assertEqual(_extract_assets({}), [])
class _Response(io.BytesIO):
def __init__(self, body, content_length='auto'):
super().__init__(body)
if content_length == 'auto':
content_length = len(body)
self.headers = {} if content_length is None else {
'Content-Length': str(content_length),
}
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return False
def _opener(response):
opener = mock.Mock()
opener.open.return_value = response
return mock.Mock(return_value=opener)
class DownloadAssetTest(unittest.TestCase):
def _download(self, response, digest=DIGEST):
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
path = update_check.download_asset(
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest,
)
try:
return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir())
finally:
path.unlink(missing_ok=True)
def test_complete_download_is_kept(self):
body, files = self._download(_Response(b'payload'))
self.assertEqual(body, b'payload')
self.assertEqual(len(files), 1)
def test_matching_digest_is_accepted(self):
digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest()
body, _ = self._download(_Response(b'payload'), digest=digest)
self.assertEqual(body, b'payload')
def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener') as opener:
with self.assertRaises(ValueError):
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest)
opener.assert_not_called()
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_truncated_download_raises_and_leaves_no_file(self):
with tempfile.TemporaryDirectory() as tmp:
resp = _Response(b'pay', content_length=None)
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
with self.assertRaises(OSError):
update_check.download_asset(
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST,
)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_digest_mismatch_raises_and_leaves_no_file(self):
with tempfile.TemporaryDirectory() as tmp:
resp = _Response(b'payload')
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
with self.assertRaises(OSError):
update_check.download_asset(
'https://example.invalid/a', Path(tmp),
suffix='.bin', digest='sha256:' + '0' * 64,
)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_missing_content_length_is_tolerated(self):
body, _ = self._download(_Response(b'payload', content_length=None))
self.assertEqual(body, b'payload')
def test_same_length_corruption_is_rejected(self):
with self.assertRaisesRegex(OSError, 'SHA-256'):
self._download(_Response(b'payloae'))
def test_uppercase_checksum_is_accepted(self):
body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper())
self.assertEqual(body, PAYLOAD)
def test_stream_failure_removes_partial_file(self):
for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)):
with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp:
response = mock.MagicMock()
response.__enter__.return_value = response
response.read.side_effect = [b'pay', error]
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
with self.assertRaises(type(error)):
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST)
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_hash_covers_every_chunk_and_read_timeout_is_set(self):
body = PAYLOAD * 20000
opener = _opener(_Response(body))
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener', opener):
path = update_check.download_asset(
'https://example.invalid/a', Path(tmp),
digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7,
)
self.assertEqual(path.read_bytes(), body)
opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7)
class FindAssetTest(unittest.TestCase):
def test_returns_asset_with_digest(self):
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}]
with mock.patch.dict(update_check._state, {'assets': assets}):
self.assertEqual(update_check.find_asset('a.deb'), assets[0])
self.assertIsNone(update_check.find_asset('b.deb'))
def test_ignores_asset_without_url(self):
with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
self.assertIsNone(update_check.find_asset('a.deb'))
def test_assets_without_a_valid_checksum_require_manual_installation(self):
for digest in ('', 'sha256:ab', 'md5:' + '0' * 32):
asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest}
with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}):
self.assertIsNone(update_check.find_asset('a.exe'))
class RunCheckTest(unittest.TestCase):
def setUp(self):
self.patch = mock.patch.object(update_check, '_state', update_check.get_status())
self.patch.start()
self.addCleanup(self.patch.stop)
def _check(self, data, current='1.11.1'):
with mock.patch.object(update_check, 'fetch_latest_release', return_value=data):
update_check.run_check(current)
return update_check.get_status()
def test_versions_are_compared_and_assets_published_together(self):
release = {'tag_name': ' v2.0 ', 'assets': [{
'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST,
}]}
for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)):
with self.subTest(current=current):
state = self._check(release, current)
self.assertTrue(state['checked'])
self.assertIsNone(state['error'])
self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead))
self.assertEqual(state['latest'], '2.0')
self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL)
self.assertEqual(state['assets'][0]['digest'], DIGEST)
def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self):
for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'},
{'tag_name': 'v2.0', 'assets': 'invalid'}):
with self.subTest(data=data):
state = self._check(data)
self.assertTrue(state['error'])
self.assertFalse(state['has_update'])
self.assertIsNone(state['latest'])
self.assertEqual(state['assets'], [])
def test_release_tag_suffixes_keep_the_existing_version_comparison(self):
state = self._check({'tag_name': 'v1.12.0-hotfix'})
self.assertIsNone(state['error'])
self.assertTrue(state['has_update'])
self.assertEqual(state['latest'], '1.12.0-hotfix')
def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self):
errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None),
TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json'))
for error in errors:
with self.subTest(error=error):
self._check({'tag_name': 'v2.0', 'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
}]})
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error):
update_check.run_check('1.11.1')
state = update_check.get_status()
self.assertTrue(state['error'])
self.assertEqual(state['assets'], [])
self.assertFalse(state['has_update'])
self.assertIsNone(self._check({'tag_name': 'v2.1'})['error'])
def test_readers_do_not_see_partially_updated_status(self):
previous = self._check({'tag_name': 'v1.11.1'})
started, finish = threading.Event(), threading.Event()
def fetch():
started.set()
finish.wait(timeout=5)
return {'tag_name': 'v2.0'}
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch):
worker = threading.Thread(target=update_check.run_check, args=('1.11.1',))
worker.start()
try:
self.assertTrue(started.wait(timeout=5))
self.assertEqual(update_check.get_status(), previous)
finally:
finish.set()
worker.join(timeout=5)
self.assertEqual(update_check.get_status()['latest'], '2.0')
def test_asset_and_status_snapshots_cannot_modify_internal_state(self):
self._check({'tag_name': 'v2.0', 'assets': [{
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
}]})
asset = update_check.find_asset('a.exe')
asset['digest'] = 'bad'
snapshot = update_check.get_status()
snapshot['assets'][0]['digest'] = 'bad'
snapshot['assets'].clear()
self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST)
def test_old_cache_is_ignored_and_no_new_cache_is_written(self):
from utils import tray_common
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / '.update_check_cache.json'
old = '{"last_attempt_at": "invalid", "assets": "invalid"}'
path.write_text(old, encoding='utf-8')
with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)):
self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update'])
self.assertEqual(path.read_text(encoding='utf-8'), old)
self.assertEqual(list(Path(tmp).iterdir()), [path])
def test_fetch_is_one_request_without_etag_and_with_timeout(self):
opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode()))
with mock.patch.object(update_check, 'build_github_opener', opener):
self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0')
args, kwargs = opener.return_value.open.call_args
self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API)
self.assertFalse(args[0].has_header('If-none-match'))
self.assertEqual(kwargs['timeout'], 7)
class RelaunchEnvTest(unittest.TestCase):
def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self):
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
env = {
'_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe',
'_MEIPASS': bundle, '_MEIPASS2': bundle,
'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')),
}
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True):
result = update_check.relaunch_env()
self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'})
if __name__ == '__main__':
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
import hashlib
import io
import sys
import tempfile
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
from utils import update_check, windows_update
class AssetNameTest(unittest.TestCase):
def test_keeps_the_running_build_even_when_renamed_or_emulated(self):
builds = (
('win-amd64', (3, 11), 'TgWsProxy_windows.exe'),
('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'),
('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'),
('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'),
('unknown', (3, 11), None),
)
for platform, version, expected in builds:
with self.subTest(platform=platform, version=version), \
mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \
mock.patch.object(sys, 'version_info', version), \
mock.patch.object(sys, 'executable', 'renamed.exe'):
self.assertEqual(windows_update.asset_name(), expected)
class ApplyUpdateTest(unittest.TestCase):
def setUp(self):
stack = ExitStack()
self.addCleanup(stack.close)
directory = stack.enter_context(tempfile.TemporaryDirectory())
self.exe = Path(directory) / 'renamed app.exe'
self.exe.write_bytes(b'old')
self.backup = self.exe.with_name('renamed app_oldtgws.exe')
self.opener = mock.Mock()
self.opener.open.return_value = io.BytesIO(b'new')
self.asset = {'url': 'https://example.invalid/new.exe',
'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()}
stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe)))
stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable']))
stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True))
stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener))
stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True))
stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True))
self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen'))
self.prepare = mock.Mock()
def test_verified_update_keeps_legacy_backup_and_launch_arguments(self):
def prepare():
self.assertEqual(self.exe.read_bytes(), b'new')
self.assertEqual(self.backup.read_bytes(), b'old')
self.launch.assert_not_called()
windows_update.apply_update(self.asset, prepare)
self.assertEqual(self.exe.read_bytes(), b'new')
self.assertEqual(self.backup.read_bytes(), b'old')
self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup})
self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable'])
self.assertEqual(self.launch.call_args[1]['creationflags'], 520)
def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self):
for digest in ('', 'sha256:' + '0' * 64):
with self.subTest(digest=digest):
self.asset['digest'] = digest
with self.assertRaises((OSError, ValueError)):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_launch_failure_restores_the_old_executable(self):
self.launch.side_effect = OSError('launch failed')
with self.assertRaisesRegex(OSError, 'launch failed'):
windows_update.apply_update(self.asset, self.prepare)
self.prepare.assert_called_once_with()
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
def test_rename_failure_leaves_current_executable_and_cleans_download(self):
with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')):
with self.assertRaises(PermissionError):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_replacement_failure_rolls_back_and_cleans_download(self):
replace = Path.replace
def fail_download_move(path, target):
if path.suffix == '.tmp':
raise PermissionError('move failed')
return replace(path, target)
with mock.patch.object(Path, 'replace', fail_download_move):
with self.assertRaisesRegex(PermissionError, 'move failed'):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.exe.read_bytes(), b'old')
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
self.prepare.assert_not_called()
self.launch.assert_not_called()
def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self):
dll = mock.Mock()
with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \
mock.patch.object(windows_update.ctypes, 'windll', dll, create=True):
windows_update.apply_update(self.asset, self.prepare)
self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1')
calls = dll.kernel32.SetDllDirectoryW.call_args_list
self.assertEqual(calls[0], mock.call(None))
self.assertEqual(calls[1].args[0].value, 'old-bundle')
@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration')
class UpdateFlowTest(unittest.TestCase):
def setUp(self):
import windows
self.windows = windows
def test_fallback_dialog_actually_installs_the_selected_asset(self):
app = self.windows
asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64}
with mock.patch.object(app, 'ctk', None), \
mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \
mock.patch.object(app, '_perform_update') as update:
app.update_ctk_form('update', asset=asset)
update.assert_called_once_with(asset)
def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self):
app = self.windows
def fail(asset, before_restart):
before_restart()
raise OSError('launch failed')
with ExitStack() as stack:
mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in (
'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex',
'_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error',
)}
exit_process = stack.enter_context(mock.patch.object(app.os, '_exit'))
mocks['apply_update'].side_effect = fail
mocks['_acquire_win_mutex'].return_value = None
app._perform_update({'url': 'asset', 'digest': 'digest'})
mocks['stop_proxy'].assert_called_once_with()
mocks['release_lock'].assert_called_once_with()
mocks['_release_win_mutex'].assert_called_once_with()
mocks['acquire_lock'].assert_called_once_with()
mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error'])
mocks['_show_error'].assert_called_once()
exit_process.assert_not_called()
def test_download_failure_leaves_proxy_and_locks_running(self):
app = self.windows
with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \
mock.patch.object(app, 'stop_proxy') as stop, \
mock.patch.object(app, 'release_lock') as unlock, \
mock.patch.object(app, '_show_error'), \
mock.patch.object(app.os, '_exit') as exit_process:
app._perform_update({})
stop.assert_not_called()
unlock.assert_not_called()
exit_process.assert_not_called()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import queue
import threading
log = logging.getLogger("tg-ws-tray")
class BackgroundTask:
def __init__(self, owner):
self.owner = owner
self.running = False
self.closed = False
self.pending = None
owner.bind("<Destroy>", self._on_destroy, add="+")
def _on_destroy(self, event):
if event.widget is self.owner:
self.closed = True
if self.pending is not None:
self.owner.after_cancel(self.pending)
self.pending = None
def start(self, work, complete, finish):
if self.running or self.closed:
return
self.running = True
results = queue.Queue()
def run():
try:
results.put((True, work()))
except Exception:
log.exception("Background task failed")
results.put((False, None))
def poll():
self.pending = None
if self.closed:
return
try:
success, result = results.get_nowait()
except queue.Empty:
self.pending = self.owner.after(50, poll)
return
self.running = False
try:
if success:
complete(result)
finally:
if not self.closed:
finish()
threading.Thread(target=run, daemon=True, name="ui-background-task").start()
self.pending = self.owner.after(50, poll)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import base64
import os
import socket as _socket
from contextlib import nullcontext
from tkinter import messagebox
from typing import Any
from proxy.utils import create_ssl_context
from ui.i18n import t
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
ctx = create_ssl_context() if secure else None
port = 443 if secure else 80
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, port), timeout=5) as raw:
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
if secure else nullcontext(raw))
with connection as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or t("connectivity.no_response")
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = t("connectivity.timeout")
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases, secure=secure)
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases, secure=secure)
def run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '', *, parent: Any) -> None:
ok = [dc for dc, v in results.items() if v is True]
total = len(_CFPROXY_TEST_DCS)
if auto_mode:
if domain:
title = t("connectivity.available", title=title_base)
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
else:
title = t("connectivity.unavailable", title=title_base)
msg = unavailable_message
else:
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
title = t("connectivity.all_ok", title=title_base)
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
elif not ok:
title = t("connectivity.unavailable", title=title_base)
errors = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.none_ok", domain=domain, errors=errors)
else:
title = t("connectivity.partial", title=title_base)
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
messagebox.showinfo(title, msg, parent=parent)
def show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC', *, parent: Any) -> None:
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
elif not ok:
all_ok = False
blocks.append(t("connectivity.multi_fail", domain=domain))
else:
all_ok = False
any_ok = True
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
blocks.append(
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
)
if all_ok:
title = t("connectivity.all_ok", title=title_base)
elif any_ok:
title = t("connectivity.partial", title=title_base)
else:
title = t("connectivity.unavailable", title=title_base)
msg = "\n\n".join(blocks)
messagebox.showinfo(title, msg, parent=parent)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_tooltip_to_widgets
def create_entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = {
"font": (theme.ui_font_family, 13), "corner_radius": radius,
"fg_color": theme.bg, "border_color": theme.field_border,
"border_width": 1, "text_color": theme.text_primary,
}
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def create_checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def create_label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def create_labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = create_label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(master=parent, value=str(value))
ent = create_entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def create_config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
create_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
def create_combobox(ctk, parent, theme, *, variable, values, command=None):
return ctk.CTkComboBox(
parent, values=values, variable=variable, command=command,
height=32, font=(theme.ui_font_family, 12),
text_color=theme.text_primary, fg_color=theme.bg,
border_color=theme.field_border, button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8, state="readonly",
)
+12
View File
@@ -75,6 +75,18 @@ def create_ctk_toplevel(
after_create: Optional[Callable[[Any], None]] = None,
) -> Any:
root = ctk.CTkToplevel()
if sys.platform == "win32":
def handle_ctrl_shortcut(event: Any) -> Optional[str]:
shortcuts = {65: ("a", "<<SelectAll>>"), 67: ("c", "<<Copy>>"), 86: ("v", "<<Paste>>")}
shortcut = shortcuts.get(event.keycode)
if shortcut and event.keysym.lower() != shortcut[0] and not event.state & 0x20000:
event.widget.event_generate(shortcut[1])
return "break"
return None
root.bind("<Control-KeyPress>", handle_ctrl_shortcut, add="+")
root.title(title)
root.resizable(False, False)
center_ctk_geometry(root, width, height)
+11 -925
View File
@@ -1,305 +1,17 @@
from __future__ import annotations
import logging
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Tuple
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.ctk_theme import (
FIRST_RUN_FRAME_PAD,
CtkTheme,
main_content_frame,
from proxy import get_link_host
from ui.ctk_controls import create_checkbox
from ui.ctk_theme import FIRST_RUN_FRAME_PAD, CtkTheme, main_content_frame
from ui.ctk_tooltip import attach_ctk_tooltip
from ui.i18n import t
from ui.settings_form import (
TrayConfigFormWidgets as TrayConfigFormWidgets,
install_tray_config_form as install_tray_config_form,
validate_config_form as validate_config_form,
)
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
from ui.i18n import (
label_from_language,
language_from_label,
language_option_labels,
set_language,
t,
)
log = logging.getLogger('tg-mtproto-proxy')
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]
_CFWORKER_TEST_DST = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100',
}
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
import base64
from contextlib import nullcontext
import ssl
import socket as _socket
ctx = ssl.create_default_context() if secure else None
port = 443 if secure else 80
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, port), timeout=5) as raw:
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
if secure else nullcontext(raw))
with connection as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {req_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Protocol: binary\r\n"
f"\r\n"
).encode()
ssock.sendall(req)
ssock.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssock.recv(512)
if not chunk:
break
buf += chunk
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
if "101" in first:
results[dc] = True
else:
results[dc] = first or t("connectivity.no_response")
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = t("connectivity.timeout")
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases, secure=secure)
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases, secure=secure)
def _run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
if v is True:
merged[dc] = True
best_domain = domain
elif dc not in merged:
merged[dc] = v
return best_domain, merged
def _show_connectivity_results(title_base: str, results: dict,
domain: str = '', label_prefix: str = 'DC',
auto_mode: bool = False,
unavailable_message: str = '') -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
ok = [dc for dc, v in results.items() if v is True]
total = len(_CFPROXY_TEST_DCS)
if auto_mode:
if domain:
title = t("connectivity.available", title=title_base)
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
else:
title = t("connectivity.unavailable", title=title_base)
msg = unavailable_message
else:
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
title = t("connectivity.all_ok", title=title_base)
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
elif not ok:
title = t("connectivity.unavailable", title=title_base)
errors = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.none_ok", domain=domain, errors=errors)
else:
title = t("connectivity.partial", title=title_base)
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
label_prefix: str = 'DC') -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
total = len(_CFPROXY_TEST_DCS)
all_ok = True
any_ok = False
blocks = []
for domain, results in per_domain.items():
ok = [dc for dc, v in results.items() if v is True]
fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total:
any_ok = True
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
elif not ok:
all_ok = False
blocks.append(t("connectivity.multi_fail", domain=domain))
else:
all_ok = False
any_ok = True
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
blocks.append(
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
)
if all_ok:
title = t("connectivity.all_ok", title=title_base)
elif any_ok:
title = t("connectivity.partial", title=title_base)
else:
title = t("connectivity.unavailable", title=title_base)
msg = "\n\n".join(blocks)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
_INNER_W = 396
_APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _appearance_options() -> List[str]:
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
def _appearance_from_cfg(value: str) -> str:
if value in _APPEARANCE_KEYS:
return t(f"appearance.{value}")
return t("appearance.auto")
def _appearance_to_cfg(label: str) -> str:
for key in _APPEARANCE_KEYS:
if t(f"appearance.{key}") == label:
return key
return "auto"
def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
combo.configure(values=[label for _, label in language_option_labels()])
var.set(label_from_language(cfg_value))
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = {
"font": (theme.ui_font_family, 13), "corner_radius": radius,
"fg_color": theme.bg, "border_color": theme.field_border,
"border_width": 1, "text_color": theme.text_primary,
}
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def _checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = _label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(value=str(value))
ent = _entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def tray_settings_scroll_and_footer(
@@ -324,632 +36,6 @@ def tray_settings_scroll_and_footer(
return scroll, footer
def _config_section(
ctk: Any,
parent: Any,
theme: CtkTheme,
title: str,
*,
bottom_spacer: int = 6,
) -> Any:
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.pack(fill="x", padx=10, pady=8)
return inner
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
no_secure_var: Any
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
cfproxy_user_domain_enabled_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_enabled_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg)
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0))
appearance_var = ctk.StringVar(
value=_appearance_from_cfg(cfg.get("appearance", "auto"))
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
cfg["appearance"] = cfg_val
ctk.CTkButton(
header, text="Donate ♥", width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open(_get_doc_url("Funding")),
),
).pack(side="right", padx=(0, 6))
ui_inner = _config_section(ctk, frame, theme, t("section.interface"))
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
ui_row.pack(fill="x")
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
language_var = ctk.StringVar(value=label_from_language(lang_cfg))
_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
anchor="w", pady=(0, 2)
)
language_combo = ctk.CTkComboBox(
lang_col,
values=[label for _, label in language_option_labels()],
variable=language_var,
height=32,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
fg_color=theme.bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
)
language_combo.pack(fill="x")
_sync_language_combobox(language_combo, language_var, lang_cfg)
_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
anchor="w", pady=(0, 2)
)
theme_combo = ctk.CTkComboBox(
theme_col,
values=_appearance_options(),
variable=appearance_var,
height=32,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
fg_color=theme.bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
command=_on_appearance_change,
)
theme_combo.pack(fill="x")
conn = _config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col, host_var = _labeled_entry(
ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]),
tip=t("tip.host"), width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = _labeled_entry(
ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]),
tip=t("tip.port"), width=100,
)
port_col.pack(side="left")
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = _labeled_entry(
ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]),
tip=t("tip.secret"), width=160, pack_fill=True,
)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="↺", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4))
cfproxy_var = ctk.BooleanVar(
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
_cf_test_btn = [None]
def _on_cf_test():
secure = not no_secure_var.get()
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
btn = _cf_test_btn[0]
if btn:
btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading
if user_domains:
def _worker():
try:
per = _run_cfproxy_multi_test(user_domains, secure=secure)
if btn:
btn.after(
0,
lambda: _show_multi_connectivity_results(
t("connectivity.cfproxy_title"), per, label_prefix='kws',
),
)
except Exception as exc:
log.error("CF proxy test failed: %s", exc)
finally:
if btn:
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
_threading.Thread(target=_worker, daemon=True).start()
else:
def _worker_auto():
try:
ok_domain, res = _run_cfproxy_auto_test(balancer.domains, secure=secure)
if btn:
btn.after(
0,
lambda: _show_connectivity_results(
t("connectivity.cfproxy_title"), res,
domain=ok_domain or '',
auto_mode=True,
unavailable_message=t("connectivity.cf_auto_fail"),
),
)
except Exception as exc:
log.error("CF proxy auto-test failed: %s", exc)
finally:
if btn:
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
_threading.Thread(target=_worker_auto, daemon=True).start()
_cf_test_widget = ctk.CTkButton(
cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack(side="right")
_cf_test_btn[0] = _cf_test_widget
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x")
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(
value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
)
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
cf_domain_entry = _entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8,
)
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
cf_worker_inner = _config_section(ctk, frame, theme, t("section.cfworker"))
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
saved_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
)
cfproxy_worker_enabled_var = ctk.BooleanVar(
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(
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
height=32, radius=8,
)
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
_cfworker_test_btn = [None]
def _sync_cfworker_test_button(*_):
btn = _cfworker_test_btn[0]
if btn is None:
return
enabled = (
cfproxy_worker_enabled_var.get()
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
)
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
btn = _cfworker_test_btn[0]
if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
return
btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading
def _worker():
try:
per = _run_cfworker_multi_test(domains, secure=secure)
btn.after(
0,
lambda: _show_multi_connectivity_results(
t("connectivity.cfworker_title"), per, label_prefix='DC',
),
)
except Exception as exc:
log.error("CF worker test failed: %s", exc)
finally:
btn.after(0, lambda: btn.configure(text=t("button.test")))
btn.after(0, _sync_cfworker_test_button)
_threading.Thread(target=_worker, daemon=True).start()
ctk.CTkButton(
cf_worker_input, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right")
_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)
_sync_cfworker_entry()
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_var = ctk.BooleanVar(value=cfg.get("no_secure", False))
no_secure_cb = _checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
no_secure_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
(t("label.pool_size"), "pool_size", t("tip.pool")),
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
]
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = _label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
adv_e = _entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
st = get_status()
check_updates_var = ctk.BooleanVar(
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
if st.get("error"):
upd_status = t("updates.status_error")
elif not st.get("checked"):
upd_status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"):
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
else:
upd_status = t("updates.status_latest")
_label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
if st.get("has_update") and on_update_click is not None:
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
upd_btn_row.grid_columnconfigure(1, weight=1)
ctk.CTkButton(
upd_btn_row, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_update_click,
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
autostart_var = None
if show_autostart:
sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = _label(
ctk, sys_inner, theme,
t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
language_var=language_var,
)
def merge_adv_from_form(
widgets: TrayConfigFormWidgets,
base: Dict[str, Any],
default_config: dict,
) -> None:
for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1]
try:
val = float(entry.get().strip())
if key in ("buf_kb", "pool_size"):
val = int(val)
base[key] = val
except ValueError:
base[key] = default_config[key]
def _dc_validation_message(error: ValueError) -> str:
exc_entry = getattr(error, "entry", None)
if exc_entry is None:
return str(error)
kind = getattr(error, "kind", "invalid")
if kind == "format":
return t("validation.dc_format", entry=exc_entry)
return t("validation.dc_invalid", entry=exc_entry)
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
import socket as _sock
host_val = widgets.host_var.get().strip()
try:
_sock.inet_aton(host_val)
except OSError:
return t("validation.bad_host")
try:
port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535):
raise ValueError
except ValueError:
return t("validation.bad_port")
lines = [
line.strip()
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if line.strip()
]
try:
parse_dc_ip_list(lines)
except ValueError as e:
return _dc_validation_message(e)
secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32:
return t("validation.bad_secret_len")
try:
bytes.fromhex(secret_val)
except ValueError:
return t("validation.bad_secret_hex")
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"secret": secret_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
if include_autostart:
new_cfg["autostart"] = (
widgets.autostart_var.get()
if widgets.autostart_var is not None
else False
)
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_user_domain_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:
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:
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
if widgets.appearance_var is not None:
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
if widgets.no_secure_var is not None:
new_cfg["no_secure"] = bool(widgets.no_secure_var.get())
return new_cfg
def install_tray_config_buttons(
ctk: Any,
frame: Any,
@@ -1051,7 +137,7 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True)
_checkbox(ctk, frame, theme, t("first_run.open_now"),
create_checkbox(ctk, frame, theme, t("first_run.open_now"),
auto_var).pack(anchor="w", pady=(0, 16))
def on_ok():
+11 -8
View File
@@ -19,6 +19,8 @@
"section.mtproto": "MTProto Connection",
"section.dc": "Telegram Data Centers (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"label.h2_enable": "Media multiplexing (HTTP/2)",
"tip.h2": "Share one HTTP/2 connection per data center and domain for media downloads through Cloudflare.\nRequires CF proxy and TLS enabled.",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Logs & Performance",
"section.updates": "Updates",
@@ -47,12 +49,12 @@
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
"tip.no_secure": "When enabled, the proxy will use port 80 for CF-proxy and CF-worker connections",
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
"tip.pool": "How many ready WebSocket connections to keep per data center.\n0 disables the direct DC->IP WebSocket route",
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
"tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset",
"tip.check_updates": "Check for updates on startup",
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for HTTP/2 and WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
"tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection",
"tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
"tip.save": "Save settings",
@@ -65,8 +67,10 @@
"button.open_release": "Open release page",
"button.start": "Get started",
"button.update": "Update",
"button.check_updates": "Check for updates",
"button.page": "Page",
"button.close": "Close",
"button.donate": "Donate ♥",
"validation.bad_host": "Invalid IP address.",
"validation.bad_port": "Port must be a number between 1 and 65535",
@@ -94,7 +98,8 @@
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
"updates.status_error": "Could not reach GitHub. Check your network.",
"updates.status_pending": "Status will appear after the background check on startup.",
"updates.status_pending": "Updates have not been checked yet.",
"updates.status_checking": "Checking for updates...",
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
"updates.status_latest": "Latest known version from GitHub is installed.",
@@ -120,7 +125,7 @@
"dialog.restart_title": "Restart?",
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
"dialog.already_running": "Application is already running.",
"dialog.already_running": "Application is already running, check the tray",
"dialog.log_not_found": "Log file has not been created yet.",
"dialog.ctk_missing": "customtkinter is not installed.",
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
@@ -134,13 +139,11 @@
"update.available": "New version available: {version}",
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
"update.ask_install": "New version available: {version}\n\nUpdate now? The app will restart.",
"update.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?",
"update.downloading": "Downloading...",
"update.replacing": "Replacing file...",
"update.restarting": "Restarting...",
"update.error": "Error: {msg}",
"update.download_fail": "Download failed:\n{error}",
"update.rename_fail": "Failed to rename file:\n{error}",
"update.move_fail": "Failed to move file:\n{error}",
"error.dc_config": "DC → IP configuration error.",
+16 -13
View File
@@ -19,6 +19,8 @@
"section.mtproto": "Подключение MTProto",
"section.dc": "Датацентры Telegram (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"label.h2_enable": "Мультиплексирование медиа (HTTP/2)",
"tip.h2": "Объединять загрузку медиа через Cloudflare в общее HTTP/2-соединение для каждого датацентра и домена.\nТребуется включённый CF-прокси и включённый TLS.",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Логи и производительность",
"section.updates": "Обновления",
@@ -26,13 +28,13 @@
"label.host": "IP-адрес",
"label.port": "Порт",
"label.secret": "Secret",
"label.secret": "Ключ",
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
"label.cf_enable": "Включить CF-прокси",
"label.cf_custom_domain": "Свой домен",
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
"label.verbose": "Подробное логирование (verbose)",
"label.no_secure": "Выключиь TLS для CF-прокси и CF-worker",
"label.no_secure": "Выключить TLS для CF-прокси и CF-worker",
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
@@ -47,12 +49,12 @@
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
"tip.pool": "Сколько готовых WebSocket-соединений держать для каждого датацентра.\n0 отключает прямой DC->IP WebSocket-маршрут",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
"tip.check_updates": "При запуске проверять наличие обновлений",
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для HTTP/2 и WS-подключений.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
"tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
"tip.save": "Сохранить настройки",
@@ -65,13 +67,15 @@
"button.open_release": "Открыть страницу релиза",
"button.start": "Начать",
"button.update": "Обновить",
"button.check_updates": "Проверить обновления",
"button.page": "Страница",
"button.close": "Закрыть",
"button.donate": "Поддержать ♥",
"validation.bad_host": "Некорректный IP-адрес.",
"validation.bad_port": "Порт должен быть числом 1-65535",
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
"validation.bad_secret_len": "Ключ должен содержать ровно 32 hex-символа (16 байт).",
"validation.bad_secret_hex": "Ключ должен состоять только из hex-символов (0-9, a-f).",
"validation.dc_format": "Неверный формат DC:IP: {entry}",
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
@@ -94,7 +98,8 @@
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
"updates.status_pending": "Обновления ещё не проверялись.",
"updates.status_checking": "Проверка обновлений...",
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
@@ -107,7 +112,7 @@
"first_run.manual": " Вручную:",
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
"first_run.manual_mtproto": " MTProto → {host} : {port}",
"first_run.manual_secret": " Secret: dd{secret}",
"first_run.manual_secret": " Ключ: dd{secret}",
"first_run.open_now": "Открыть прокси в Telegram сейчас",
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
@@ -120,7 +125,7 @@
"dialog.restart_title": "Перезапустить?",
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
"dialog.already_running": "Приложение уже запущено.",
"dialog.already_running": "Приложение уже запущено, проверьте трей.",
"dialog.log_not_found": "Файл логов ещё не создан.",
"dialog.ctk_missing": "customtkinter не установлен.",
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
@@ -134,13 +139,11 @@
"update.available": "Доступна новая версия: {version}",
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
"update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.",
"update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?",
"update.downloading": "Скачивание...",
"update.replacing": "Замена файла...",
"update.restarting": "Перезапуск...",
"update.error": "Ошибка: {msg}",
"update.download_fail": "Не удалось скачать:\n{error}",
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
"update.move_fail": "Не удалось переместить файл:\n{error}",
"error.dc_config": "Ошибка конфигурации DC → IP.",
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import math
import socket
from copy import deepcopy
from dataclasses import dataclass
from typing import Union
from proxy import coerce_domain_list, parse_dc_ip_list
from ui.i18n import t
UI_ONLY_KEYS = frozenset({
"appearance", "autostart", "check_updates", "language"
})
@dataclass(frozen=True)
class SettingsChange:
config: dict
changed_keys: frozenset
@property
def requires_restart(self) -> bool:
return bool(self.changed_keys - UI_ONLY_KEYS)
def prepare_settings(current: dict, values: dict, defaults: dict) -> SettingsChange:
config = deepcopy(defaults)
config.update(deepcopy(current))
changed_keys = frozenset(
key for key, value in values.items() if value != config.get(key)
)
config.update(deepcopy(values))
return SettingsChange(config, changed_keys)
def validate_settings(values: dict, defaults: dict) -> Union[dict, str]:
config = dict(values)
host = values["host"].strip()
try:
socket.inet_aton(host)
except OSError:
return t("validation.bad_host")
try:
port = int(values["port"].strip())
if not 1 <= port <= 65535:
raise ValueError
except ValueError:
return t("validation.bad_port")
lines = [line.strip() for line in values["dc_ip"].splitlines() if line.strip()]
try:
parse_dc_ip_list(lines)
except ValueError as exc:
entry = getattr(exc, "entry", None)
if entry is None:
return str(exc)
key = "dc_format" if getattr(exc, "kind", "invalid") == "format" else "dc_invalid"
return t(f"validation.{key}", entry=entry)
secret = values["secret"].strip()
if len(secret) != 32:
return t("validation.bad_secret_len")
if any(char not in "0123456789abcdefABCDEF" for char in secret):
return t("validation.bad_secret_hex")
config.update(host=host, port=port, secret=secret, dc_ip=lines)
for key in ("buf_kb", "pool_size", "log_max_mb"):
try:
value = float(values[key].strip())
if not math.isfinite(value):
raise ValueError
config[key] = int(value) if key != "log_max_mb" else value
except (ValueError, OverflowError):
config[key] = defaults[key]
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
if key in config:
config[key] = coerce_domain_list(config[key])
return config
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import logging
import threading
from copy import deepcopy
from tkinter import messagebox
from ui.settings_form import validate_config_form
from ui.i18n import set_language, t
from ui.settings import prepare_settings
log = logging.getLogger("tg-ws-tray")
class SettingsDialog:
def __init__(
self, *, ctk, root, widgets, config, defaults, persist,
refresh_menu, finish, restart, include_autostart=False,
apply_autostart=None, read_autostart=None,
):
self.ctk = ctk
self.root = root
self.widgets = widgets
self.config = config
self.defaults = defaults
self.persist = persist
self.refresh_menu = refresh_menu
self.finish = finish
self.restart = restart
self.include_autostart = include_autostart
self.apply_autostart = apply_autostart
self.read_autostart = read_autostart
self.closed = False
def cancel(self):
if self.closed:
return
mode = self.config.get("appearance", "auto")
self.ctk.set_appearance_mode("system" if mode == "auto" else mode)
set_language(self.config.get("language", self.defaults["language"]))
self.refresh_menu()
self._finish()
def _finish(self):
self.closed = True
self.finish()
def save(self):
if self.closed:
return
values = validate_config_form(
self.widgets, self.defaults, include_autostart=self.include_autostart,
)
if isinstance(values, str):
messagebox.showerror(t("app.error_title"), values, parent=self.root)
return
change = prepare_settings(self.config, values, self.defaults)
autostart_changed = (
self.include_autostart
and self.read_autostart is not None
and bool(values["autostart"]) != self.read_autostart()
)
if not change.changed_keys and not autostart_changed:
self._finish()
return
try:
self.persist(change.config)
except (OSError, ValueError, TypeError) as exc:
log.exception("Failed to save settings")
messagebox.showerror(t("app.error_title"), str(exc), parent=self.root)
return
self.config.update(deepcopy(change.config))
set_language(change.config["language"])
changed_keys = change.changed_keys | ({"autostart"} if autostart_changed else set())
log.info("Settings saved: %s", ", ".join(sorted(changed_keys)))
if self.apply_autostart is not None:
self.apply_autostart(bool(change.config.get("autostart", False)))
self.refresh_menu()
do_restart = change.requires_restart and messagebox.askyesno(
t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root,
)
self._finish()
if do_restart:
threading.Thread(
target=self.restart, args=(deepcopy(change.config),),
daemon=True, name="proxy-restart",
).start()
+619
View File
@@ -0,0 +1,619 @@
from __future__ import annotations
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Union
from proxy import __version__, coerce_domain_list
from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
from ui.background_task import BackgroundTask
from ui.connectivity import (
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
show_connectivity_results, show_multi_connectivity_results,
)
from ui.ctk_controls import (
create_entry, create_checkbox, create_label, create_labeled_entry,
create_config_section, create_combobox,
)
from ui.ctk_theme import CtkTheme
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
from ui.settings import validate_settings
from ui.i18n import (
label_from_language, language_from_label, language_option_labels,
set_language, get_language, t,
)
_INNER_W = 396
_APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _get_doc_url(doc_name: str) -> str:
lang = get_language().value
lang_folder = "EN" if lang == "en" else "RU"
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
def _appearance_options() -> List[str]:
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
def _appearance_from_cfg(value: str) -> str:
if value in _APPEARANCE_KEYS:
return t(f"appearance.{value}")
return t("appearance.auto")
def _appearance_to_cfg(label: str) -> str:
for key in _APPEARANCE_KEYS:
if t(f"appearance.{key}") == label:
return key
return "auto"
@dataclass
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
no_secure_var: Any
advanced_vars: Dict[str, Any]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
h2_var: Optional[Any] = None
cfproxy_user_domain_enabled_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_enabled_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form(
ctk: Any,
frame: Any,
theme: CtkTheme,
cfg: dict,
default_config: dict,
*,
show_autostart: bool = False,
autostart_value: bool = False,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
set_language(cfg.get("language", default_config["language"]))
no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False))
_create_header(ctk, frame, theme)
appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config)
host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config)
dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config)
cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
verbose_var, advanced_vars = _create_logging(
ctk, frame, theme, cfg, default_config, no_secure_var,
)
check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click)
autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value)
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
advanced_vars=advanced_vars,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
h2_var=h2_var,
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var,
language_var=language_var,
)
def _create_header(ctk, frame, theme):
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0))
ctk.CTkButton(
header, text=t("button.donate"), width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color="#22c55e", hover_color="#16a34a",
text_color="#ffffff", border_width=0,
command=lambda: (
header.winfo_toplevel().iconify(),
webbrowser.open(_get_doc_url("Funding")),
),
).pack(side="right", padx=(0, 6))
def _create_interface(ctk, frame, theme, cfg, default_config):
lang_cfg = cfg.get("language", default_config["language"])
appearance_var = ctk.StringVar(
master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto"))
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
ui_inner = create_config_section(ctk, frame, theme, t("section.interface"))
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
ui_row.pack(fill="x")
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
language_var = ctk.StringVar(master=frame, value=label_from_language(lang_cfg))
create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, lang_col, theme, variable=language_var,
values=[label for _, label in language_option_labels()],
).pack(fill="x")
create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
anchor="w", pady=(0, 2)
)
create_combobox(
ctk, theme_col, theme, variable=appearance_var,
values=_appearance_options(), command=_on_appearance_change,
).pack(fill="x")
return appearance_var, language_var
def _create_connection(ctk, frame, theme, cfg, default_config):
conn = create_config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col, host_var = create_labeled_entry(
ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]),
tip=t("tip.host"), width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = create_labeled_entry(
ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]),
tip=t("tip.port"), width=100,
)
port_col.pack(side="left")
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = create_labeled_entry(
ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]),
tip=t("tip.secret"), width=160, pack_fill=True,
)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="↺", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
return host_var, port_var, secret_var
def _create_dc(ctk, frame, theme, cfg, default_config):
dc_inner = create_config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
return dc_textbox
def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4))
cfproxy_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True))
)
cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
cf_test = BackgroundTask(frame.winfo_toplevel())
def _on_cf_test():
secure = not no_secure_var.get()
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
)
if cf_test.running:
return
_cf_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cf_test_widget.configure(text=t("button.test"), state="normal")
if user_domains:
cf_test.start(
lambda: run_cfproxy_multi_test(user_domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfproxy_title"), per, label_prefix="kws",
parent=frame.winfo_toplevel(),
),
finish,
)
else:
domains = list(balancer.domains)
cf_test.start(
lambda: run_cfproxy_auto_test(domains, secure=secure),
lambda result: show_connectivity_results(
t("connectivity.cfproxy_title"), result[1],
domain=result[0] or "", auto_mode=True,
unavailable_message=t("connectivity.cf_auto_fail"),
parent=frame.winfo_toplevel(),
),
finish,
)
_cf_test_widget = ctk.CTkButton(
cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cf_test,
)
_cf_test_widget.pack(side="right")
h2_var = ctk.BooleanVar(master=frame, value=cfg.get("h2", default_config.get("h2", True)))
h2_cb = create_checkbox(ctk, cf_inner, theme, t("label.h2_enable"), h2_var)
h2_cb.pack(anchor="w", pady=(2, 8))
attach_ctk_tooltip(h2_cb, t("tip.h2"))
def _sync_h2(*_):
enabled = cfproxy_var.get() and not no_secure_var.get() and not cfg.get("force_test_dc", False)
h2_cb.configure(state="normal" if enabled else "disabled")
cfproxy_var.trace_add("write", _sync_h2)
no_secure_var.trace_add("write", _sync_h2)
_sync_h2()
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_custom_row.pack(fill="x")
saved_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
)
cf_custom_cb_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
)
cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
).pack(side="right")
cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains))
cf_domain_entry = create_entry(
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
height=32, radius=8,
)
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled"
cf_domain_entry.configure(state=state)
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry()
return cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var
def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var):
cf_worker_inner = create_config_section(ctk, frame, theme, t("section.cfworker"))
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_input.pack(fill="x")
saved_worker_domains = coerce_domain_list(
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
)
cfproxy_worker_enabled_var = ctk.BooleanVar(
master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
)
cf_worker_cb = create_checkbox(
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
cfproxy_worker_enabled_var,
)
cf_worker_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains))
cf_worker_entry = create_entry(
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
height=32, radius=8,
)
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
worker_test = BackgroundTask(frame.winfo_toplevel())
def _sync_cfworker_test_button(*_):
btn = _cfworker_test_widget
enabled = (
not worker_test.running and cfproxy_worker_enabled_var.get()
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
)
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains:
return
_cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled")
def finish():
_cfworker_test_widget.configure(text=t("button.test"))
_sync_cfworker_test_button()
worker_test.start(
lambda: run_cfworker_multi_test(domains, secure=secure),
lambda per: show_multi_connectivity_results(
t("connectivity.cfworker_title"), per, label_prefix="DC",
parent=frame.winfo_toplevel(),
),
finish,
)
ctk.CTkButton(
cf_worker_input, text="?", width=28, height=32,
font=(theme.ui_font_family, 14), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
).pack(side="right")
_cfworker_test_widget = ctk.CTkButton(
cf_worker_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=_on_cfworker_test,
)
_cfworker_test_widget.pack(side="right")
def _sync_cfworker_entry(*_):
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
cf_worker_entry.configure(state=state)
_sync_cfworker_test_button()
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_entry()
return cfproxy_worker_enabled_var, cfproxy_worker_domain_var
def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
log_inner = create_config_section(ctk, frame, theme, t("section.logs"))
verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False))
verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_cb = create_checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
no_secure_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
adv_rows = [
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
(t("label.pool_size"), "pool_size", t("tip.pool")),
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
]
advanced_vars = {}
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = create_label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key])))
adv_e = create_entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
var=advanced_vars[key],
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
return verbose_var, advanced_vars
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
check_updates_var = ctk.BooleanVar(
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
status_label = create_label(ctk, upd_inner, theme, "", size=11,
justify="left", wraplength=_INNER_W)
status_label.pack(anchor="w", pady=(0, 8))
check_task = BackgroundTask(frame.winfo_toplevel())
def check():
if check_task.running or check_task.closed:
return
check_button.configure(state="disabled")
status_label.configure(text=t("updates.status_checking"))
check_task.start(
lambda: run_check(__version__), lambda _: refresh(),
lambda: check_button.configure(state="normal"),
)
check_button = ctk.CTkButton(
upd_inner, text=t("button.check_updates"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", command=check,
)
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
release_button = ctk.CTkButton(
upd_btn_row, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
)
release_button.grid(row=0, column=0, sticky="ew")
update_button = ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", command=on_update_click,
)
def refresh():
st = get_status()
if st.get("error"):
status = t("updates.status_error")
elif not st.get("checked"):
status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"):
status = t("updates.status_available", latest=st["latest"], current=__version__)
elif st.get("ahead_of_release") and st.get("latest"):
status = t("updates.status_ahead", current=__version__, latest=st["latest"])
else:
status = t("updates.status_latest")
status_label.configure(text=status)
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
release_button.configure(command=lambda: webbrowser.open(rel_url))
can_update = bool(st.get("has_update") and on_update_click is not None)
upd_btn_row.grid_columnconfigure(1, weight=int(can_update))
release_button.grid_configure(padx=(0, 4) if can_update else 0)
if can_update:
update_button.grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
update_button.grid_remove()
def sync_manual_check(*_):
if check_updates_var.get():
check_button.pack_forget()
else:
check_button.pack(fill="x", pady=(0, 8), before=upd_btn_row)
check_updates_var.trace_add("write", sync_manual_check)
sync_manual_check()
refresh()
return check_updates_var
def _create_autostart(ctk, frame, theme, show_autostart, autostart_value):
autostart_var = None
if show_autostart:
sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(master=frame, value=autostart_value)
as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = create_label(
ctk, sys_inner, theme,
t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return autostart_var
def validate_config_form(
widgets: TrayConfigFormWidgets,
default_config: dict,
*,
include_autostart: bool,
) -> Union[dict, str]:
values = {
"host": widgets.host_var.get(),
"port": widgets.port_var.get(),
"secret": widgets.secret_var.get(),
"dc_ip": widgets.dc_textbox.get("1.0", "end"),
"verbose": bool(widgets.verbose_var.get()),
}
for key, var in widgets.advanced_vars.items():
values[key] = var.get()
for key in (
"check_updates", "cfproxy", "h2", "cfproxy_user_domain_enabled",
"cfproxy_worker_enabled", "no_secure",
):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = bool(var.get())
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
var = getattr(widgets, f"{key}_var")
if var is not None:
values[key] = var.get()
if include_autostart:
values["autostart"] = (
bool(widgets.autostart_var.get())
if widgets.autostart_var is not None else False
)
if widgets.appearance_var is not None:
values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
values["language"] = language_from_label(widgets.language_var.get()).value
return validate_settings(values, default_config)
+4 -1
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import sys
import os
from typing import Any, Dict
from copy import deepcopy
from ui.i18n import detect_system_language
@@ -20,17 +21,19 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"buf_kb": 256,
"pool_size": 4,
"cfproxy": True,
"h2": True,
"cfproxy_user_domain_enabled": False,
"cfproxy_user_domain": [],
"cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [],
"force_test_dc": False,
"no_secure": False,
"appearance": "auto",
}
def default_tray_config() -> Dict[str, Any]:
cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg = deepcopy(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex()
cfg["language"] = detect_system_language().value
+154
View File
@@ -0,0 +1,154 @@
"""
In-place update on Linux.
Detects how the app is installed (deb/rpm package or a standalone binary)
and updates it without a manual download: packages go through the package
manager, a standalone binary is replaced with the release file. Without
write access the install runs through pkexec. Unsupported setups (AUR, pip,
non-amd64, no pkexec) yield None and the caller shows the release page.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from http.client import HTTPException
from pathlib import Path
from typing import List, Optional
from utils.update_check import download_asset, relaunch_env
BINARY_ASSET = "TgWsProxy_linux_amd64"
# install kind -> release asset suffix
ASSET_SUFFIX = {"binary": "", "deb": ".deb", "rpm": ".rpm"}
_INSTALL_TIMEOUT_SEC = 600.0
def _run_quiet(cmd: List[str]) -> bool:
try:
return subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
env=relaunch_env(),
).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def exe_path() -> Path:
return Path(os.path.realpath(sys.executable))
def _is_root() -> bool:
return hasattr(os, "geteuid") and os.geteuid() == 0
def _can_elevate() -> bool:
return _is_root() or bool(shutil.which("pkexec"))
def _elevate() -> List[str]:
return [] if _is_root() else ["pkexec"]
def detect_install_kind(exe: Optional[Path] = None) -> Optional[str]:
"""How the app is installed: "deb", "rpm", "binary" or None."""
exe = exe or exe_path()
if platform.machine().lower() not in ("x86_64", "amd64"):
return None # releases ship amd64 only
if shutil.which("pacman") and _run_quiet(["pacman", "-Qo", str(exe)]):
return None # AUR install, updated by its own package manager
can_elevate = _can_elevate()
if shutil.which("dpkg-query") and _run_quiet(["dpkg-query", "-S", str(exe)]):
return "deb" if can_elevate else None
if shutil.which("rpm") and _run_quiet(["rpm", "-qf", str(exe)]):
return "rpm" if can_elevate else None
# replacing the binary needs a writable directory or root
if os.access(str(exe.parent), os.W_OK) or can_elevate:
return "binary"
return None
def asset_name(kind: str) -> str:
return BINARY_ASSET + ASSET_SUFFIX[kind]
def install_cmd(kind: str, path: Path) -> List[str]:
"""Command that installs the downloaded package."""
elevate = _elevate()
if kind == "deb":
if shutil.which("apt-get"):
return elevate + [
"apt-get", "install", "-y", str(path),
]
return elevate + ["dpkg", "-i", str(path)]
if kind == "rpm":
if shutil.which("dnf"):
return elevate + ["dnf", "install", "-y", str(path)]
return elevate + ["rpm", "-U", str(path)]
raise ValueError("no install command for kind: {0}".format(kind))
def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
"""Stage beside the executable so an interrupted copy cannot destroy it."""
script = '''set -e
staged=$(mktemp "$2.XXXXXX")
trap 'rm -f -- "$staged"' EXIT
install -m 755 -- "$1" "$staged"
mv -fT -- "$staged" "$2"
'''
return _elevate() + ["sh", "-c", script, "sh", str(src), str(exe)]
def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]:
if log:
log.info("Linux update: %s", " ".join(cmd))
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=_INSTALL_TIMEOUT_SEC,
env=relaunch_env(),
)
if proc.returncode == 0:
return None
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
return err[-500:] or "exit code {0}".format(proc.returncode)
def apply_update(
kind: str, url: str, digest: str = "",
log: Optional[logging.Logger] = None,
) -> Optional[str]:
"""Download and install the update.
Returns:
Error text, or None on success.
"""
exe = exe_path()
suffix = ASSET_SUFFIX[kind] or ".new"
# with a writable directory download next to the binary and os.replace it
in_place = kind == "binary" and os.access(str(exe.parent), os.W_OK)
if log:
log.info("Linux update (%s): downloading %s", kind, url)
try:
with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
if kind != "binary":
return _run_install(install_cmd(kind, tmp), log=log)
if in_place:
os.chmod(str(tmp), 0o755)
os.replace(str(tmp), str(exe))
return None
return _run_install(replace_binary_cmd(tmp, exe), log=log)
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
return str(exc)
+33 -15
View File
@@ -11,6 +11,7 @@ import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
from copy import deepcopy
import psutil
@@ -211,7 +212,7 @@ def load_config() -> dict:
except Exception as exc:
log.warning("Failed to load config: %s", repr(exc))
if cfg is None:
cfg = dict(DEFAULT_CONFIG)
cfg = deepcopy(DEFAULT_CONFIG)
_apply_ui_language(cfg)
return cfg
@@ -357,6 +358,7 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.cfproxy_h2_media = cfg.get("h2", DEFAULT_CONFIG["h2"])
cfproxy_user_domains = coerce_domain_list(
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
)
@@ -458,41 +460,57 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
# update check
def maybe_notify_update(
def check_update_async(
cfg: dict,
is_exiting: Callable[[], bool],
ask_open: Callable[[str, str], bool],
on_update: Callable[[str, str], None],
) -> None:
"""Check the latest release in the background.
on_update(version, release_page_url) is called from the worker thread
when a newer version is available.
"""
if not cfg.get("check_updates", True):
return
def _work():
def _work() -> None:
time.sleep(1.5)
if is_exiting():
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
import webbrowser
run_check(__version__)
st = get_status()
if not st.get("has_update"):
if not st.get("has_update") or is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
from ui.i18n import t
if ask_open(
t("update.ask_open", version=ver),
t("app.update_title"),
):
webbrowser.open(url)
on_update(
st.get("latest") or "?",
(st.get("html_url") or "").strip() or RELEASES_PAGE_URL,
)
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
def maybe_notify_update(
cfg: dict,
is_exiting: Callable[[], bool],
ask_open: Callable[[str, str], bool],
) -> None:
"""Check for updates and offer to open the release page."""
def _on_update(version: str, url: str) -> None:
import webbrowser
from ui.i18n import t
if ask_open(t("update.ask_open", version=version), t("app.update_title")):
webbrowser.open(url)
check_update_async(cfg, is_exiting, _on_update)
# ctk thread (windows / linux)
_ctk_root: Any = None
+115 -278
View File
@@ -1,324 +1,161 @@
"""
Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
"""
"""One startup check against GitHub Releases and verified asset downloads."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import sys
import time
import tempfile
from contextlib import suppress
from http.client import HTTPException
from itertools import zip_longest
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from typing import Any, Dict, Optional
from urllib.request import Request
from proxy.utils import build_github_opener
REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
_MIN_FETCH_INTERVAL_SEC = 3600.0
_SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE)
_state: Dict[str, Any] = {
"checked": False,
"has_update": False,
"ahead_of_release": False,
"latest": None,
"html_url": None,
"html_url": RELEASES_PAGE_URL,
"error": None,
"assets": [],
}
def _cache_file() -> Optional[Path]:
try:
from utils.tray_common import APP_DIR
root = APP_DIR
root.mkdir(parents=True, exist_ok=True)
return root / ".update_check_cache.json"
except OSError:
return None
def _load_cache(path: Optional[Path]) -> Dict[str, Any]:
if not path or not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None:
if not path:
return
try:
path.write_text(json.dumps(data), encoding="utf-8")
except OSError:
pass
def _parse_version_tuple(s: str) -> tuple:
s = (s or "").strip().lstrip("vV")
if not s:
return (0,)
parts = []
for seg in s.split("."):
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
if digits:
try:
parts.append(int(digits))
except ValueError:
parts.append(0)
else:
parts.append(0)
return tuple(parts) if parts else (0,)
for part in (s or "").strip().lstrip("vV").split("."):
match = re.match(r"\d+", part)
parts.append(int(match[0]) if match else 0)
return tuple(parts)
def _version_gt(a: str, b: str) -> bool:
"""True, если версия a новее b (простое сравнение по сегментам)."""
ta = _parse_version_tuple(a)
tb = _parse_version_tuple(b)
for x, y in zip_longest(ta, tb, fillvalue=0):
if x > y:
return True
if x < y:
return False
for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0):
if x != y:
return x > y
return False
def _apply_release_tag(
tag: str, html_url: str, current_version: str,
) -> None:
global _state
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
return
latest_clean = tag.lstrip("vV")
cur = (current_version or "").strip().lstrip("vV")
_state["latest"] = latest_clean
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
_state["has_update"] = _version_gt(latest_clean, cur)
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
cur, latest_clean
)
def fetch_latest_release(
timeout: float = 12.0,
etag: Optional[str] = None,
) -> Tuple[Optional[dict], Optional[str], int]:
"""
GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код).
"""
headers = {
def fetch_latest_release(timeout: float = 12.0) -> dict:
req = Request(RELEASES_LATEST_API, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
}
if etag:
headers["If-None-Match"] = etag
req = Request(
RELEASES_LATEST_API,
headers=headers,
method="GET",
)
try:
with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode()
new_etag = resp.headers.get("ETag")
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), new_etag, int(code)
except HTTPError as e:
if e.code == 304:
hdrs = e.headers
new_etag = hdrs.get("ETag") if hdrs else None
return None, new_etag or etag, 304
raise
})
with build_github_opener().open(req, timeout=timeout) as resp:
return json.load(resp)
def run_check(current_version: str) -> None:
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
global _state
_state["checked"] = True
_state["error"] = None
cache_path = _cache_file()
cache = _load_cache(cache_path)
now = time.time()
last_attempt = float(cache.get("last_attempt_at") or 0)
if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC:
tag = (cache.get("tag_name") or "").strip()
if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
_state["assets"] = cache.get("assets") or []
return
err = cache.get("last_error")
_state["error"] = (
err if err else "Проверка обновлений отложена (интервал между запросами)."
)
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = RELEASES_PAGE_URL
return
etag = (cache.get("etag") or "").strip() or None
try:
data, new_etag, code = fetch_latest_release(etag=etag)
cache["last_attempt_at"] = now
if code == 304:
tag = (cache.get("tag_name") or "").strip()
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
_apply_release_tag(tag, url, current_version)
_state["assets"] = cache.get("assets") or []
if new_etag:
cache["etag"] = new_etag
_save_cache(cache_path, cache)
return
assert data is not None
tag = (data.get("tag_name") or "").strip()
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url
else:
_apply_release_tag(tag, html_url, current_version)
if new_etag:
cache["etag"] = new_etag
cache["tag_name"] = tag
cache["html_url"] = html_url
assets = [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
_state["assets"] = assets
cache["assets"] = assets
cache.pop("last_error", None)
_save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
cache["last_attempt_at"] = now
msg = str(e)
if isinstance(e, HTTPError) and e.code == 403:
msg = (
"GitHub API вернул 403 (лимит или доступ). Повторите позже."
)
cache["last_error"] = msg
_save_cache(cache_path, cache)
_state["error"] = msg
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = RELEASES_PAGE_URL
def fetch_release_by_tag(
tag: str, timeout: float = 12.0,
) -> Tuple[Optional[dict], int]:
if not tag:
return None, 0
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
}
req = Request(
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
headers=headers,
method="GET",
)
try:
with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode()
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), int(code)
except HTTPError as e:
if e.code in [304, 404]:
return None, e.code
raise
def _extract_assets(data: Optional[dict]) -> list:
if not data:
return []
def _extract_assets(data: dict) -> list:
assets = (data or {}).get("assets") or []
if not isinstance(assets, list):
raise ValueError("Invalid release assets from GitHub")
return [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
{"name": a["name"], "url": a["browser_download_url"],
"digest": a.get("digest") if isinstance(a.get("digest"), str) else ""}
for a in assets
if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"]
and isinstance(a.get("browser_download_url"), str) and a["browser_download_url"]
]
def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state)
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
new_assets = _state.get("assets") or []
if not new_assets:
return None
target_name = None
# SHA256 match
def run_check(current_version: str) -> None:
"""Publish the complete result at once; no persistent cache or error cooldown."""
global _state
state = {
"checked": True, "has_update": False, "ahead_of_release": False,
"latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [],
}
try:
import hashlib
data, code = fetch_release_by_tag(f"v{current_version}")
if code == 200 and data:
cur_assets = _extract_assets(data)
if cur_assets:
h = hashlib.sha256()
with open(exe_path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
exe_sha = h.hexdigest().lower()
for a in cur_assets:
d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha:
target_name = a["name"]
break
except Exception:
pass
data = fetch_latest_release()
if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str):
raise ValueError("Invalid release from GitHub")
latest = data["tag_name"].strip().lstrip("vV")
if not latest or not latest[0].isdigit():
raise ValueError("Invalid release version from GitHub")
state.update(
latest=latest,
has_update=_version_gt(latest, current_version),
ahead_of_release=_version_gt(current_version, latest),
html_url=data.get("html_url") or RELEASES_PAGE_URL,
assets=_extract_assets(data),
)
except (OSError, ValueError, HTTPException) as exc:
state["error"] = str(exc) or type(exc).__name__
logging.getLogger(__name__).warning("Update check failed: %s", exc)
_state = state
# Fallback
if not target_name or target_name not in [a.get("name") for a in new_assets]:
import platform
import struct
is_64 = struct.calcsize("P") * 8 == 64
machine = platform.machine().lower()
is_arm64 = machine in ("arm64", "aarch64")
def download_asset(
url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "",
timeout: float = 30.0,
) -> Path:
"""Return a temporary file only after its mandatory SHA-256 has matched."""
expected = _SHA256.fullmatch(digest if isinstance(digest, str) else "")
if not expected:
raise ValueError("The release asset has no valid SHA-256 checksum")
try:
is_modern = sys.getwindowsversion().major >= 10
except Exception:
is_modern = True
fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix, prefix="TgWsProxy_tmp_")
path = Path(name)
try:
sha = hashlib.sha256()
with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp:
while True:
chunk = resp.read(65536)
if not chunk:
break
out.write(chunk)
sha.update(chunk)
if sha.hexdigest() != expected[1].lower():
raise OSError("SHA-256 checksum does not match the release asset")
return path
except BaseException:
with suppress(OSError):
path.unlink()
raise
if is_arm64:
target_name = "TgWsProxy_windows_arm64.exe"
elif is_modern:
target_name = "TgWsProxy_windows.exe"
elif is_64:
target_name = "TgWsProxy_windows_7_64bit.exe"
else:
target_name = "TgWsProxy_windows_7_32bit.exe"
for a in new_assets:
if a.get("name") == target_name:
return a["url"], a["name"]
def find_asset(name: str) -> Optional[Dict[str, str]]:
"""Find an asset suitable for automatic installation."""
for asset in _state["assets"]:
if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]):
return dict(asset)
return None
def get_status() -> Dict[str, Any]:
state = _state
return {**state, "assets": [dict(a) for a in state["assets"]]}
def relaunch_env() -> Dict[str, str]:
"""Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6."""
env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))}
mei = getattr(sys, "_MEIPASS", None)
if mei:
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
for var in ("PATH", "LD_LIBRARY_PATH", "GI_TYPELIB_PATH", "GTK_PATH",
"GST_PLUGIN_PATH", "GIO_MODULE_DIR"):
original = env.pop(var + "_ORIG", None)
if original is not None:
env[var] = original
elif mei and env.get(var):
bundle = os.path.normcase(os.path.abspath(mei))
kept = [p for p in env[var].split(os.pathsep)
if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)]
if kept:
env[var] = os.pathsep.join(kept)
else:
env.pop(var, None)
return env
+54
View File
@@ -0,0 +1,54 @@
"""Windows build selection and executable replacement with rollback."""
from __future__ import annotations
import ctypes
import subprocess
import sys
import sysconfig
from contextlib import suppress
from pathlib import Path
from typing import Callable, Optional
from utils.update_check import download_asset, relaunch_env
def asset_name() -> Optional[str]:
# Use the running build, including x86/x64 emulation and renamed executables.
target = sysconfig.get_platform()
if target == "win-arm64":
return "TgWsProxy_windows_arm64.exe"
if target == "win32":
return "TgWsProxy_windows_7_32bit.exe"
if target == "win-amd64":
return "TgWsProxy_windows_7_64bit.exe" if sys.version_info < (3, 9) else "TgWsProxy_windows.exe"
return None
def apply_update(asset: dict, before_restart: Callable[[], None]) -> None:
"""Verify, replace and launch; keep the old executable if any step fails."""
exe = Path(sys.executable)
backup = exe.with_name(exe.stem + "_oldtgws.exe")
downloaded = download_asset(asset["url"], exe.parent, digest=asset["digest"])
try:
backup.unlink(missing_ok=True)
exe.rename(backup)
try:
downloaded.replace(exe)
before_restart()
bundle = getattr(sys, "_MEIPASS", None)
if bundle:
ctypes.windll.kernel32.SetDllDirectoryW(None)
try:
subprocess.Popen(
[str(exe), *sys.argv[1:]], env=relaunch_env(),
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
finally:
if bundle:
ctypes.windll.kernel32.SetDllDirectoryW(ctypes.c_wchar_p(bundle))
except BaseException:
backup.replace(exe)
raise
finally:
with suppress(OSError):
downloaded.unlink(missing_ok=True)
+57 -189
View File
@@ -2,17 +2,14 @@ from __future__ import annotations
import ctypes
import os
import subprocess
import sys
import threading
import time
import webbrowser
import winreg
import tempfile
from pathlib import Path
from typing import Optional
from proxy.utils import build_github_opener
try:
import pyperclip
@@ -42,24 +39,23 @@ from utils.win32_theme import (
)
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
acquire_lock, bootstrap, check_ipv6_warning, check_update_async, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from utils.update_check import (
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
)
from utils.update_check import find_asset, get_status, RELEASES_PAGE_URL
from utils.windows_update import apply_update, asset_name
from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
from ui.i18n import set_language, t
from ui.i18n import t
from ui.settings_dialog import SettingsDialog
_tray_icon: Optional[object] = None
_config: dict = {}
@@ -128,14 +124,16 @@ def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
def update_ctk_form(
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
text: str, title: Optional[str] = None, asset: Optional[dict] = None,
release_url: Optional[str] = None,
) -> str:
title = title or t("app.name")
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
if not asset:
return "open" if _ask_yes_no(t("update.ask_open", version=get_status()["latest"]), title) else "close"
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES:
return "update"
_perform_update(asset)
if result == _IDNO:
return "open"
return "close"
@@ -147,10 +145,10 @@ def update_ctk_form(
root = create_ctk_toplevel(
ctk,
title=title,
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
width=310 if asset else 210,
height=130 if asset else 100,
theme=theme,
topmost=False,
topmost=True,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -185,21 +183,16 @@ def update_ctk_form(
done.set()
def _on_update() -> None:
if not download_url:
if release_url:
webbrowser.open(release_url)
_close_with("open")
return
for b in btns:
b.configure(state="disabled")
root.protocol("WM_DELETE_WINDOW", lambda: None)
def _run():
_perform_update(download_url, set_status=_set_status)
_perform_update(asset, set_status=_set_status)
root.after(0, lambda: [b.configure(state="normal") for b in btns])
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
threading.Thread(target=_run, daemon=True).start()
if IS_FROZEN:
if asset:
btn_upd = ctk.CTkButton(
row, text=t("button.update"), width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update,
@@ -228,97 +221,37 @@ def update_ctk_form(
return result["value"]
def _perform_update(download_url: str, set_status=None) -> None:
def _perform_update(asset: dict, set_status=None) -> None:
restarting = False
def _step(msg: str) -> None:
log.info("Update: %s", msg)
if set_status:
set_status(msg)
time.sleep(0.8)
def _err(msg: str) -> None:
log.error("Update error: %s", msg)
if set_status:
set_status(f"{t('update.error', msg=msg)}")
else:
_show_error(msg)
def _before_restart() -> None:
nonlocal restarting
_step(t("update.restarting"))
restarting = True
stop_proxy()
release_lock()
_release_win_mutex()
_step(t("update.downloading"))
cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None
try:
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
log.info("Downloading update from %s", download_url)
opener = build_github_opener()
with opener.open(download_url) as _resp:
with open(str(tmp_path), "wb") as _fout:
while True:
_chunk = _resp.read(65536)
if not _chunk:
break
_fout.write(_chunk)
apply_update(asset, _before_restart)
except Exception as exc:
_err(t("update.download_fail", error=exc))
if tmp_path:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
log.exception("Update failed")
if restarting:
mutex = _acquire_win_mutex()
if mutex is True or mutex is None and acquire_lock():
start_proxy(_config, _show_error)
msg = t("update.error", msg=exc)
if set_status:
set_status(msg)
else:
_show_error(msg)
return
_step(t("update.replacing"))
try:
if old_exe.exists():
old_exe.unlink()
cur_exe.rename(old_exe)
except Exception as exc:
_err(t("update.rename_fail", error=exc))
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
try:
tmp_path.rename(cur_exe)
except Exception as exc:
_err(t("update.move_fail", error=exc))
try:
old_exe.rename(cur_exe)
except OSError:
pass
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step(t("update.restarting"))
_release_win_mutex()
stop_proxy()
# Don't reuse existing _MEI* dir
env = os.environ.copy()
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
del env[_k]
if hasattr(sys, "_MEIPASS"):
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
env["PATH"] = os.pathsep.join(
p for p in env.get("PATH", "").split(os.pathsep)
if os.path.normcase(p.rstrip("\\/")) != _mei
)
try:
subprocess.Popen(
[str(cur_exe)],
env=env,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
except Exception as exc:
log.error("Failed to launch updated exe: %s", exc)
time.sleep(0.5)
os._exit(0)
@@ -339,13 +272,15 @@ def _trigger_update_flow(icon=None, item=None) -> None:
st = get_status()
if not st.get("has_update"):
return
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
asset = find_asset(asset_name()) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
asset=asset,
release_url=url,
)
if choice == "open":
@@ -358,27 +293,6 @@ def _trigger_update_flow(icon=None, item=None) -> None:
threading.Thread(target=_show, daemon=True, name="manual-update").start()
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
run_check(__version__)
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
if is_exiting():
return
_trigger_update_flow()
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
# autostart (registry)
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -500,8 +414,6 @@ def _edit_config_dialog() -> None:
cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled()
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
@@ -521,72 +433,29 @@ def _edit_config_dialog() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
)
_original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None:
root.destroy()
done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish()
dialog = SettingsDialog(
ctk=ctk, root=root, widgets=widgets, config=_config,
defaults=DEFAULT_CONFIG, persist=save_config,
refresh_menu=_refresh_tray_menu, finish=_finish,
restart=lambda config: restart_proxy(config, _show_error),
include_autostart=_supports_autostart(),
apply_autostart=set_autostart_enabled if _supports_autostart() else None,
read_autostart=is_autostart_enabled if _supports_autostart() else None,
)
def on_save() -> None:
from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
if isinstance(merged, str):
messagebox.showerror(t("app.error_title"), merged, parent=root)
return
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_restore_ui_locale()
_finish()
return
save_config(merged)
_config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged)
if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
t("dialog.restart_title"),
t("dialog.restart_body"),
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
ctk_run_dialog(_build)
@@ -679,7 +548,7 @@ def run_tray() -> None:
return
start_proxy(_config, _show_error)
_maybe_do_update(_config, lambda: _exiting)
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
_show_first_run()
check_ipv6_warning(_show_info)
@@ -698,14 +567,13 @@ def main() -> None:
if IS_FROZEN:
def _cleanup_old_exes():
exe_dir = Path(sys.executable).parent
time.sleep(3)
for _f in exe_dir.glob("*_oldtgws.exe"):
try:
_f.unlink()
log.info("Deleted leftover: %s", _f)
except OSError:
pass
exe = Path(sys.executable)
backup = exe.with_name(exe.stem + "_oldtgws.exe")
try:
backup.unlink(missing_ok=True)
except OSError:
pass
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
try: