mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-31 20:16:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bdd69ade9 | ||
|
|
50c980e9ea | ||
|
|
48954ff9ab | ||
|
|
fe1580cd83 | ||
|
|
ad7f3cae9e | ||
|
|
caa949bee0 | ||
|
|
3d59c813aa | ||
|
|
70b982da2c | ||
|
|
5226a409d4 | ||
|
|
e519c4df4c | ||
|
|
a0605c7447 | ||
|
|
222cabeee0 | ||
|
|
c2da669576 | ||
|
|
2c9a0b8a90 | ||
|
|
87977962dc | ||
|
|
5a61d8f782 | ||
|
|
f200e33fd2 | ||
|
|
c02398fbc0 | ||
|
|
5f55245184 | ||
|
|
312d844eea | ||
|
|
be485a3851 | ||
|
|
f811a10918 | ||
|
|
55ec6a91df | ||
|
|
b23c8f6122 | ||
|
|
d36bb7df71 | ||
|
|
bd4f3d080c | ||
|
|
492d999248 | ||
|
|
e8ad0d6958 | ||
|
|
5eb7e007d3 | ||
|
|
1ae2f3a147 | ||
|
|
d2b6c450eb | ||
|
|
1b8671b3d3 |
No files matched your search
@@ -1 +0,0 @@
|
||||
custom: ['https://nowpayments.io/donation/flowseal']
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
path: dist/TgWsProxy_macos_universal.dmg
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.inputs.build_linux == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -395,6 +395,8 @@ jobs:
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
python3-gi \
|
||||
python3-gi-cairo \
|
||||
gir1.2-appindicator3-0.1 \
|
||||
gir1.2-ayatanaappindicator3-0.1 \
|
||||
python3-tk
|
||||
|
||||
|
||||
@@ -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) за информацию.
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -49,12 +49,14 @@ 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`) |
|
||||
| `--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 |
|
||||
@@ -64,13 +66,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
|
||||
```bash
|
||||
# Standard startup
|
||||
tg-ws-proxy
|
||||
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
|
||||
|
||||
# Different port and additional DCs
|
||||
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||
|
||||
# With verbose logging
|
||||
tg-ws-proxy -v
|
||||
# With verbose logging, without direct connections to DCs
|
||||
tg-ws-proxy -v --dc-ip
|
||||
|
||||
# Fake TLS masquerading (ee-secret)
|
||||
tg-ws-proxy --fake-tls-domain example.com
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Other coins**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
The project is completely free for everyone.
|
||||
However, its development and stable operation as the user base grows require investment.
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Other coins**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
> [!TIP]
|
||||
>
|
||||
> ### 🎉 Поддержать меня
|
||||
>
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
Проект полностью бесплатен для всех.
|
||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||
@@ -20,7 +20,6 @@
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
|
||||
@@ -49,12 +49,14 @@ 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`) |
|
||||
| `--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` | Количество сохранений логов после перезаписи |
|
||||
@@ -64,13 +66,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
|
||||
```bash
|
||||
# Стандартный запуск
|
||||
tg-ws-proxy
|
||||
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
|
||||
|
||||
# Другой порт и дополнительные DC
|
||||
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||
|
||||
# С подробным логированием
|
||||
tg-ws-proxy -v
|
||||
# С подробным логированием, без прямого подключения к DC
|
||||
tg-ws-proxy -v --dc-ip
|
||||
|
||||
# Fake TLS маскировка (ee-secret)
|
||||
tg-ws-proxy --fake-tls-domain example.com
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
Проект полностью бесплатен для всех.
|
||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||
|
||||
@@ -24,13 +24,13 @@ from utils.tray_common import (
|
||||
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 = {}
|
||||
@@ -147,8 +147,8 @@ def _edit_config_dialog() -> None:
|
||||
theme = ctk_theme_for_platform()
|
||||
w, h = CONFIG_DIALOG_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||
after_create=_apply_window_icon,
|
||||
ctk, title=t("app.settings_title"), width=w, height=h,
|
||||
theme=theme, topmost=False, after_create=_apply_window_icon
|
||||
)
|
||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
@@ -158,68 +158,24 @@ 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,
|
||||
)
|
||||
|
||||
_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)
|
||||
|
||||
@@ -244,7 +200,7 @@ def _show_first_run() -> None:
|
||||
w, h = FIRST_RUN_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||
after_create=_apply_window_icon,
|
||||
topmost=False, after_create=_apply_window_icon,
|
||||
)
|
||||
|
||||
def on_done(open_tg: bool) -> None:
|
||||
@@ -310,7 +266,47 @@ def run_tray() -> None:
|
||||
log.info("Tray app exited")
|
||||
|
||||
|
||||
def _smoke_test() -> None:
|
||||
"""Exercise the frozen GUI stack without starting the proxy or writing config."""
|
||||
import gi
|
||||
|
||||
gi.require_version('AppIndicator3', '0.1')
|
||||
gi.require_version('AyatanaAppIndicator3', '0.1')
|
||||
from gi.repository import AppIndicator3, AyatanaAppIndicator3
|
||||
|
||||
# Namespace imports alone do not resolve app_indicator_new: call it too.
|
||||
indicators = [
|
||||
namespace.Indicator.new(
|
||||
'tg-ws-proxy-smoke-test', '', namespace.IndicatorCategory.APPLICATION_STATUS,
|
||||
)
|
||||
for namespace in (AppIndicator3, AyatanaAppIndicator3)
|
||||
]
|
||||
icon = pystray.Icon('tg-ws-proxy-smoke-test', Image.new('RGB', (16, 16)))
|
||||
root = ctk.CTk()
|
||||
root.withdraw()
|
||||
root.update_idletasks()
|
||||
root.destroy()
|
||||
|
||||
# A successful constructor on the build host can hide missing bundled
|
||||
# libraries. Check where the dynamic loader actually obtained them.
|
||||
bundle_dir = os.path.realpath(sys._MEIPASS) + os.sep
|
||||
prefixes = ('libglib-', 'libgobject-', 'libgio-', 'libgtk-',
|
||||
'libappindicator', 'libayatana-', 'libdbusmenu-')
|
||||
with open('/proc/self/maps', encoding='utf-8') as maps:
|
||||
paths = {line.split(maxsplit=5)[-1].strip() for line in maps}
|
||||
for path in sorted(paths):
|
||||
if os.path.basename(path).startswith(prefixes):
|
||||
if not os.path.realpath(path).startswith(bundle_dir):
|
||||
raise RuntimeError('GUI library loaded outside the bundle: ' + path)
|
||||
print(path)
|
||||
assert icon is not None and all(indicators)
|
||||
print('Linux bundle smoke test passed')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if sys.argv[1:] == ['--smoke-test']:
|
||||
_smoke_test()
|
||||
return
|
||||
if not acquire_lock():
|
||||
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||
return
|
||||
|
||||
@@ -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,
|
||||
@@ -348,6 +348,7 @@ def _edit_config_dialog() -> None:
|
||||
width=width,
|
||||
height=height,
|
||||
theme=theme,
|
||||
topmost=False,
|
||||
after_create=lambda window: _activate_app(),
|
||||
)
|
||||
_settings_window = root
|
||||
@@ -361,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,
|
||||
@@ -370,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
|
||||
@@ -385,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")
|
||||
@@ -471,6 +415,7 @@ def _show_first_run() -> None:
|
||||
width=width,
|
||||
height=height,
|
||||
theme=theme,
|
||||
topmost=False,
|
||||
after_create=lambda window: _activate_app(),
|
||||
)
|
||||
|
||||
|
||||
+42
-16
@@ -1,10 +1,9 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
import os
|
||||
import glob
|
||||
|
||||
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
block_cipher = None
|
||||
|
||||
@@ -15,22 +14,21 @@ certifi_datas = collect_data_files('certifi')
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
|
||||
gi_hiddenimports = collect_submodules('gi')
|
||||
gi_datas = collect_data_files('gi')
|
||||
|
||||
# Collect GObject typelib files from the system
|
||||
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0')
|
||||
typelib_datas = []
|
||||
for d in typelib_dirs:
|
||||
typelib_datas.append((d, 'gi_typelibs'))
|
||||
appindicator_binaries = [
|
||||
(path, '.')
|
||||
for pattern in ('/usr/lib/*/libappindicator3.so.1',
|
||||
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
|
||||
for path in glob.glob(pattern)
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas,
|
||||
binaries=appindicator_binaries,
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
'pystray._appindicator',
|
||||
'PIL._tkinter_finder',
|
||||
'customtkinter',
|
||||
@@ -39,15 +37,22 @@ a = Analysis(
|
||||
'cryptography.hazmat.primitives.ciphers.modes',
|
||||
'cryptography.hazmat.backends.openssl',
|
||||
'gi',
|
||||
'_gi',
|
||||
'gi.repository.GLib',
|
||||
'gi.repository.GObject',
|
||||
'gi.repository.Gtk',
|
||||
'gi.repository.Gdk',
|
||||
'gi.repository.DBus',
|
||||
'gi.repository.AppIndicator3',
|
||||
'gi.repository.AyatanaAppIndicator3',
|
||||
] + gi_hiddenimports,
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
hooksconfig={
|
||||
'gi': {
|
||||
'icons': [],
|
||||
'themes': [],
|
||||
'languages': ['en', 'ru'],
|
||||
},
|
||||
},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
@@ -58,6 +63,27 @@ a = Analysis(
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
_required_libraries = {
|
||||
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
|
||||
'libgtk-3.so.0', 'libappindicator3.so.1',
|
||||
'libayatana-appindicator3.so.1',
|
||||
}
|
||||
_required_typelibs = {
|
||||
'AppIndicator3-0.1.typelib', 'AyatanaAppIndicator3-0.1.typelib', 'DBus-1.0.typelib',
|
||||
}
|
||||
_bundled_libraries = {
|
||||
os.path.basename(name)
|
||||
for name, _, kind in a.binaries + a.datas
|
||||
if kind in ('BINARY', 'SYMLINK')
|
||||
}
|
||||
_missing = (
|
||||
_required_libraries - _bundled_libraries
|
||||
) | (
|
||||
_required_typelibs - {os.path.basename(name) for name, _, _ in a.datas}
|
||||
)
|
||||
if _missing:
|
||||
raise RuntimeError('Incomplete Linux GI bundle: ' + ', '.join(sorted(_missing)))
|
||||
|
||||
_PIL_EXCLUDE_PYDS = {
|
||||
'_avif', '_webp', '_imagingtk',
|
||||
'FpxImagePlugin', 'MicImagePlugin',
|
||||
|
||||
@@ -17,6 +17,8 @@ a = Analysis(
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
'tkinter',
|
||||
'customtkinter',
|
||||
'pystray._darwin',
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 10, 0, 0),
|
||||
prodvers=(1, 10, 0, 0),
|
||||
filevers=(1, 11, 0, 0),
|
||||
prodvers=(1, 11, 0, 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.0.0'),
|
||||
StringStruct(u'FileVersion', u'1.11.0.0'),
|
||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||
StringStruct(u'ProductVersion', u'1.10.0.0'),
|
||||
StringStruct(u'ProductVersion', u'1.11.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -20,6 +20,8 @@ a = Analysis(
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
'pystray._win32',
|
||||
'PIL._tkinter_finder',
|
||||
'customtkinter',
|
||||
|
||||
+1
-1
@@ -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.0"
|
||||
__version__ = "1.11.0"
|
||||
|
||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||
+89
-17
@@ -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 *
|
||||
@@ -12,11 +13,25 @@ from .config import proxy_config
|
||||
from .raw_websocket import RawWebSocket
|
||||
from .pool import cf_worker_pool
|
||||
from ._aes import Cipher, algorithms, modes
|
||||
from .network_debug import WsActivity
|
||||
from .cf_h2 import bridge_h2
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
_st_I_le = struct.Struct('<I')
|
||||
|
||||
TCP_BACKOFF_INITIAL = 30.0
|
||||
TCP_BACKOFF_MAX = 3600.0
|
||||
_tcp_failures: Dict[Tuple[str, int], int] = {}
|
||||
_tcp_retry_after: Dict[Tuple[str, int], float] = {}
|
||||
_tcp_connecting: Set[Tuple[str, int]] = set()
|
||||
|
||||
|
||||
def reset_tcp_backoff() -> None:
|
||||
_tcp_failures.clear()
|
||||
_tcp_retry_after.clear()
|
||||
_tcp_connecting.clear()
|
||||
|
||||
ZERO_64 = b'\x00' * 64
|
||||
|
||||
|
||||
@@ -130,7 +145,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 +169,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 +183,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)
|
||||
@@ -201,7 +221,8 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||
|
||||
try:
|
||||
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||
timeout=10.0, path=path)
|
||||
timeout=10.0, path=path,
|
||||
secure=not proxy_config.disable_secure)
|
||||
break
|
||||
except Exception as exc:
|
||||
cf_worker_pool.report_failure(worker_domain, exc)
|
||||
@@ -230,11 +251,13 @@ 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}'
|
||||
try:
|
||||
ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
|
||||
ws = await RawWebSocket.connect(domain, domain, timeout=10.0,
|
||||
secure=not proxy_config.disable_secure)
|
||||
chosen_domain = base_domain
|
||||
break
|
||||
except Exception as exc:
|
||||
@@ -244,6 +267,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)
|
||||
|
||||
@@ -256,19 +281,45 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||
|
||||
|
||||
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
||||
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,
|
||||
@@ -288,6 +339,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
|
||||
@@ -298,9 +350,15 @@ 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
|
||||
@@ -310,12 +368,20 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
parts = splitter.split(chunk)
|
||||
if not parts:
|
||||
continue
|
||||
if activity is not None:
|
||||
activity.send_started()
|
||||
if len(parts) > 1:
|
||||
await ws.send_batch(parts)
|
||||
else:
|
||||
await ws.send(parts[0])
|
||||
if activity is not None:
|
||||
activity.sent(sum(map(len, parts)))
|
||||
else:
|
||||
if activity is not None:
|
||||
activity.send_started()
|
||||
await ws.send(chunk)
|
||||
if activity is not None:
|
||||
activity.sent(len(chunk))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
@@ -334,6 +400,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
|
||||
@@ -341,6 +409,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:
|
||||
@@ -364,6 +434,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,
|
||||
|
||||
+948
@@ -0,0 +1,948 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from .balancer import balancer
|
||||
from .h2_transport import H2Transport
|
||||
from .stats import stats
|
||||
from .utils import (
|
||||
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
|
||||
PROTO_TAG_SECURE, create_ssl_context,
|
||||
)
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
MAX_PACKET = 4 * 1024 * 1024
|
||||
MAX_CHANNEL_REQUESTS = 8
|
||||
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
|
||||
MAX_LANE_REQUESTS = 64
|
||||
MAX_LANE_BYTES = 32 * 1024 * 1024
|
||||
MAX_LANE_REPLY_BYTES = 32 * 1024 * 1024
|
||||
DOMAIN_COOLDOWN = 30.0
|
||||
REQUEST_TIMEOUT = 65.0
|
||||
SETUP_TIMEOUT = 8.0
|
||||
REPLAY_IDLE_SECONDS = 1.0
|
||||
REPLAY_REQUEST_SECONDS = 3.0
|
||||
REPLAY_CHECK_SECONDS = 0.25
|
||||
REPLAY_MAX_AGE = 30.0
|
||||
REPLAY_HISTORY_BYTES = 64 * 1024
|
||||
REPLAY_HISTORY_PACKETS = 16
|
||||
MAX_CHANNEL_REPLAYS = 2
|
||||
REPLAY_SLOT_SECONDS = 30.0
|
||||
REPLAY_RETRY_SECONDS = 2.0
|
||||
REPLAY_MAX_ATTEMPTS = 3
|
||||
SLOW_RESPONSE_SECONDS = 3.0
|
||||
DIAGNOSTIC_INTERVAL = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RequestTrace:
|
||||
packet: int
|
||||
started: float
|
||||
replay: bool
|
||||
size: int
|
||||
request: int = 0
|
||||
stream: int = 0
|
||||
phase: str = 'queued'
|
||||
sent_at: Optional[float] = None
|
||||
headers_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
received: int = 0
|
||||
cf_ray: str = '-'
|
||||
|
||||
def summary(self, now: float) -> str:
|
||||
end = self.finished_at if self.finished_at is not None else now
|
||||
headers = self.headers_at if self.headers_at is not None else end
|
||||
send_ms = -1 if self.sent_at is None else (self.sent_at - self.started) * 1000
|
||||
headers_ms = -1 if self.sent_at is None else max(0, headers - self.sent_at) * 1000
|
||||
return ('req=%d pkt=%d sid=%d replay=%d phase=%s age_ms=%.0f '
|
||||
'send_ms=%.0f wait_headers_ms=%.0f up=%d down=%d ray=%s' % (
|
||||
self.request, self.packet, self.stream, self.replay, self.phase,
|
||||
(end - self.started) * 1000,
|
||||
send_ms, headers_ms,
|
||||
self.size, self.received, self.cf_ray))
|
||||
|
||||
|
||||
class _NativeDeliveryError(ConnectionError):
|
||||
"""The local receiver disconnected"""
|
||||
|
||||
|
||||
class _ReplyBufferFull(BufferError):
|
||||
"""A local memory limit"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ReplayPacket:
|
||||
sent_at: float
|
||||
body: bytes
|
||||
sequence: int
|
||||
attempts: int = 0
|
||||
last_attempt_at: float = 0.0
|
||||
pending: Optional[asyncio.Task] = None
|
||||
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
|
||||
retired: bool = False
|
||||
|
||||
@property
|
||||
def replayed(self) -> bool:
|
||||
return self.attempts > 0
|
||||
|
||||
|
||||
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
|
||||
async def plaintext(count: int) -> bytes:
|
||||
return decryptor.update(await reader.readexactly(count))
|
||||
|
||||
if tag == PROTO_TAG_ABRIDGED:
|
||||
first = (await plaintext(1))[0]
|
||||
quick, words = bool(first & 0x80), first & 0x7f
|
||||
if words == 0x7f:
|
||||
words = int.from_bytes(await plaintext(3), 'little')
|
||||
length = words * 4
|
||||
else:
|
||||
value, = struct.unpack('<I', await plaintext(4))
|
||||
quick, length = bool(value & 0x80000000), value & 0x7fffffff
|
||||
if not 24 <= length <= MAX_PACKET + (15 if tag == PROTO_TAG_SECURE else 0):
|
||||
raise ValueError('native packet length outside supported range: %d' % length)
|
||||
body = await plaintext(length)
|
||||
if tag == PROTO_TAG_SECURE:
|
||||
if body[:8] == b'\x00' * 8:
|
||||
packet_length = 20 + int.from_bytes(body[16:20], 'little')
|
||||
else:
|
||||
packet_length = 24 + ((length - 24) // 16) * 16
|
||||
if not 24 <= packet_length <= length or length - packet_length > 15:
|
||||
raise ValueError('invalid padded native packet')
|
||||
body = body[:packet_length]
|
||||
if len(body) % 4 or len(body) > MAX_PACKET:
|
||||
raise ValueError('invalid native packet alignment or size')
|
||||
return body, quick
|
||||
|
||||
|
||||
def _encode_reply(body: bytes, tag: bytes) -> bytes:
|
||||
if not body or len(body) % 4 or len(body) > MAX_PACKET:
|
||||
raise ValueError('invalid HTTP MTProto response length: %d' % len(body))
|
||||
if tag == PROTO_TAG_ABRIDGED:
|
||||
words = len(body) // 4
|
||||
prefix = bytes([words]) if words < 0x7f else b'\x7f' + words.to_bytes(3, 'little')
|
||||
return prefix + body
|
||||
padding = os.urandom(os.urandom(1)[0] % 4) if tag == PROTO_TAG_SECURE else b''
|
||||
return struct.pack('<I', len(body) + len(padding)) + body + padding
|
||||
|
||||
|
||||
class _HttpChannel:
|
||||
def __init__(self, lane, channel_id: int, label: str):
|
||||
self.lane, self.channel_id, self.label = lane, channel_id, label
|
||||
self.closed = False
|
||||
self.pending: Dict[asyncio.Task, int] = {}
|
||||
self.pending_since: Dict[asyncio.Task, float] = {}
|
||||
self.sent_since: Dict[asyncio.Task, float] = {}
|
||||
self.pending_bytes = 0
|
||||
self.reply_bytes = 0
|
||||
self.queue = asyncio.Queue(maxsize=16)
|
||||
self.capacity = asyncio.Condition()
|
||||
self.recovery_wakeup = asyncio.Event()
|
||||
self.requests = self.up = self.down = self.quick_requests = 0
|
||||
self.started = time.monotonic()
|
||||
self.receiving_tasks: Set[asyncio.Task] = set()
|
||||
self.delivering_since: Optional[float] = None
|
||||
self.replay_pending: Set[asyncio.Task] = set()
|
||||
self.replay_history: List[_ReplayPacket] = []
|
||||
self.replay_key: Optional[bytes] = None
|
||||
self.replay_requests = 0
|
||||
self.poll_requests = 0
|
||||
self.first_poll_gap = 0.0
|
||||
self.replay_rotations = 0
|
||||
self.capacity_rotations = 0
|
||||
self.last_progress = self.started
|
||||
self.last_native_send = self.started
|
||||
self.close_lock = asyncio.Lock()
|
||||
self.send_lock = asyncio.Lock()
|
||||
self.close_done = False
|
||||
self.request_traces: Dict[asyncio.Task, _RequestTrace] = {}
|
||||
self.recent_traces = deque(maxlen=16)
|
||||
self.upload_wait_since: Optional[float] = None
|
||||
self.upload_wait_phase = '-'
|
||||
self.upload_wait_bytes = 0
|
||||
self.http_bytes = self.small_responses = self.large_responses = 0
|
||||
self.flow_sample = (self.started, 0, 0, 0, 0, 0, 0, 0)
|
||||
lane.channels.add(self)
|
||||
|
||||
@property
|
||||
def receiving_http(self) -> int:
|
||||
return len(self.receiving_tasks)
|
||||
|
||||
@property
|
||||
def native_backpressure(self) -> bool:
|
||||
return bool(self.reply_bytes) or self.delivering_since is not None
|
||||
|
||||
def _has_capacity(self, length: int) -> bool:
|
||||
return len(self.pending) < MAX_CHANNEL_REQUESTS and self.pending_bytes + length <= MAX_CHANNEL_BYTES
|
||||
|
||||
def _wake_receiver(self) -> None:
|
||||
if not self.closed and not self.pending and not self.native_backpressure:
|
||||
self.recovery_wakeup.set()
|
||||
|
||||
def _overdue_original(self, entry: _ReplayPacket, now: float) -> bool:
|
||||
return entry.retired or any(task in self.sent_since and task not in self.receiving_tasks
|
||||
and now - self.sent_since[task] >= REPLAY_REQUEST_SECONDS
|
||||
for task in entry.originals)
|
||||
|
||||
def _replay_limit(self, entry: _ReplayPacket) -> int:
|
||||
return REPLAY_MAX_ATTEMPTS if entry.originals or entry.retired or entry is self.replay_history[-1] else 0
|
||||
|
||||
def _capacity_candidate(self, now: float):
|
||||
for task, sent in sorted(self.sent_since.items(), key=lambda pair: (
|
||||
pair[0] not in self.replay_pending, pair[1])):
|
||||
if (task.done() or task in self.receiving_tasks
|
||||
or now - sent < REPLAY_REQUEST_SECONDS):
|
||||
continue
|
||||
if task in self.replay_pending:
|
||||
return task, None
|
||||
entry = next((entry for entry in self.replay_history if task in entry.originals), None)
|
||||
if entry is not None and now - entry.sent_at <= REPLAY_MAX_AGE:
|
||||
return task, entry
|
||||
return None
|
||||
|
||||
async def _make_upload_room(self, now: float) -> None:
|
||||
if self.closed or self.upload_wait_since is None:
|
||||
return
|
||||
if self.upload_wait_phase == 'channel-capacity':
|
||||
if self._has_capacity(self.upload_wait_bytes):
|
||||
return
|
||||
channels = [self]
|
||||
elif self.upload_wait_phase == 'lane-capacity':
|
||||
if self.lane._has_capacity(self.upload_wait_bytes):
|
||||
return
|
||||
channels = sorted(self.lane.channels, key=lambda channel: min(
|
||||
channel.sent_since.values(), default=now))
|
||||
else:
|
||||
return
|
||||
for channel in channels:
|
||||
candidate = channel._capacity_candidate(now)
|
||||
if candidate is None:
|
||||
continue
|
||||
task, entry = candidate
|
||||
channel.sent_since.pop(task, None)
|
||||
if entry is not None:
|
||||
entry.retired = True
|
||||
trace = channel.request_traces.get(task)
|
||||
if trace is not None:
|
||||
trace.phase = 'capacity-' + trace.phase
|
||||
if now >= self.lane.next_capacity_log:
|
||||
self.lane.next_capacity_log = now + DIAGNOSTIC_INTERVAL
|
||||
log.debug('H2 CAPACITY lane=%d waiting_channel=%d release_channel=%d '
|
||||
'reason=%s replay=%d wait_ms=%.0f inflight=%d',
|
||||
self.lane.lane_id, self.channel_id, channel.channel_id,
|
||||
self.upload_wait_phase, task in channel.replay_pending,
|
||||
(now - self.upload_wait_since) * 1000, self.lane.inflight)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
channel.capacity_rotations += 1
|
||||
self.lane.capacity_rotations += 1
|
||||
return
|
||||
|
||||
def _replay_blocker(self, entry: _ReplayPacket, now: float) -> Optional[str]:
|
||||
if not 0 <= now - entry.sent_at <= REPLAY_MAX_AGE:
|
||||
return 'expired'
|
||||
if entry.attempts >= self._replay_limit(entry):
|
||||
return 'budget-used'
|
||||
if self.native_backpressure:
|
||||
return 'native-backpressure'
|
||||
if entry.originals.intersection(self.receiving_tasks):
|
||||
return 'original-body'
|
||||
if entry.pending in self.receiving_tasks:
|
||||
return 'replay-body'
|
||||
if entry.replayed and now - entry.last_attempt_at < REPLAY_RETRY_SECONDS:
|
||||
return 'backoff-or-progress'
|
||||
if self._overdue_original(entry, now):
|
||||
return None
|
||||
if (entry is not self.replay_history[-1]
|
||||
or any(task is not entry.pending for task in self.pending)):
|
||||
return 'receiver-active'
|
||||
if entry.replayed and now - self.last_progress < REPLAY_IDLE_SECONDS:
|
||||
return 'backoff-or-progress'
|
||||
return None
|
||||
|
||||
def _replay_due(self, entry: _ReplayPacket, now: float) -> bool:
|
||||
return self._replay_blocker(entry, now) is None
|
||||
|
||||
def _wait_state(self, now: float) -> str:
|
||||
oldest = min(self.pending_since.values(), default=now)
|
||||
recovery_left = sum(max(0, self._replay_limit(entry) - entry.attempts)
|
||||
for entry in self.replay_history if now - entry.sent_at <= REPLAY_MAX_AGE)
|
||||
waiting_headers = sum(task not in self.replay_pending and task not in self.receiving_tasks
|
||||
and now - since >= REPLAY_REQUEST_SECONDS
|
||||
for task, since in self.pending_since.items())
|
||||
return ('channel=%d pending=%d replays=%d receiving=%d ready_bytes=%d '
|
||||
'native_write=%d idle_ms=%.0f oldest_ms=%.0f native_age_ms=%.0f '
|
||||
'recovery_left=%d waiting_headers=%d' % (
|
||||
self.channel_id, len(self.pending), len(self.replay_pending),
|
||||
self.receiving_http, self.reply_bytes, self.delivering_since is not None,
|
||||
(now - self.last_progress) * 1000, (now - oldest) * 1000,
|
||||
(now - self.last_native_send) * 1000, recovery_left, waiting_headers))
|
||||
|
||||
def _request_detail(self, trace: _RequestTrace, now: float) -> str:
|
||||
entry = next((entry for entry in self.replay_history if entry.sequence == trace.packet), None)
|
||||
recovery = 'not-retained'
|
||||
attempts = '-'
|
||||
if entry is not None:
|
||||
attempts = '%d/%d' % (entry.attempts, self._replay_limit(entry))
|
||||
blocker = self._replay_blocker(entry, now)
|
||||
if blocker is not None:
|
||||
recovery = blocker
|
||||
elif entry.pending is not None:
|
||||
recovery = ('replay-body' if entry.pending in self.receiving_tasks else
|
||||
'probe-%.0fms' % ((now - self.pending_since.get(entry.pending, now)) * 1000))
|
||||
elif len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
|
||||
recovery = 'replay-slots'
|
||||
elif not self._has_capacity(len(entry.body)):
|
||||
recovery = 'channel-capacity'
|
||||
elif not self.lane._has_capacity(len(entry.body)):
|
||||
recovery = 'lane-capacity'
|
||||
elif self.send_lock.locked():
|
||||
recovery = 'send-lock'
|
||||
else:
|
||||
recovery = 'eligible'
|
||||
related = [item for item in self.recent_traces if item.packet == trace.packet][-2:]
|
||||
return ('channel=%d %s attempts=%s recovery=%s recent=[%s]' % (
|
||||
self.channel_id, trace.summary(now), attempts, recovery,
|
||||
'; '.join(item.summary(now) for item in related)))
|
||||
|
||||
def _flow_state(self, now: float) -> str:
|
||||
previous = self.flow_sample
|
||||
current = (now, self.requests, self.replay_requests, self.http_bytes,
|
||||
self.down, self.small_responses, self.large_responses, self.poll_requests)
|
||||
self.flow_sample = current
|
||||
poll_gap, self.first_poll_gap = self.first_poll_gap, 0.0
|
||||
probes = [trace.summary(now) for trace in self.request_traces.values() if trace.replay][:2]
|
||||
return ('channel=%d window_ms=%.0f requests=%d replays=%d http_down=%d native_down=%d '
|
||||
'small=%d large=%d pending=%d ready=%d upload_wait=%s/%.0fms native_write_ms=%.0f '
|
||||
'capacity_rotations=%d polls=%d first_poll_ms=%.0f probes=[%s]' % (
|
||||
self.channel_id, (now - previous[0]) * 1000,
|
||||
*(current[index] - previous[index] for index in range(1, 7)),
|
||||
len(self.pending), self.reply_bytes, self.upload_wait_phase,
|
||||
0 if self.upload_wait_since is None else (now - self.upload_wait_since) * 1000,
|
||||
0 if self.delivering_since is None else (now - self.delivering_since) * 1000,
|
||||
self.capacity_rotations,
|
||||
current[7] - previous[7], poll_gap * 1000,
|
||||
'; '.join(probes)))
|
||||
|
||||
def _fail(self, error: Exception) -> None:
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
self.recovery_wakeup.set()
|
||||
while not self.queue.empty():
|
||||
self.queue.get_nowait()
|
||||
self.reply_bytes = 0
|
||||
self.queue.put_nowait(error)
|
||||
|
||||
async def send(self, body: bytes, quick: bool, replay: bool = False) -> bool:
|
||||
if replay and self.send_lock.locked():
|
||||
return False
|
||||
if not replay:
|
||||
self.upload_wait_since = time.monotonic()
|
||||
self.upload_wait_phase = 'send-lock'
|
||||
self.upload_wait_bytes = len(body)
|
||||
try:
|
||||
async with self.send_lock:
|
||||
return await self._send(body, quick, replay)
|
||||
finally:
|
||||
if not replay:
|
||||
self.upload_wait_since = None
|
||||
self.upload_wait_phase = '-'
|
||||
self.upload_wait_bytes = 0
|
||||
|
||||
async def _send(self, body: bytes, quick: bool, replay: bool) -> bool:
|
||||
async with self.capacity:
|
||||
if replay and not self._has_capacity(len(body)):
|
||||
return False
|
||||
if not replay:
|
||||
self.upload_wait_phase = 'channel-capacity'
|
||||
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(len(body))),
|
||||
REQUEST_TIMEOUT)
|
||||
if self.closed:
|
||||
raise ConnectionError('H2 channel closed')
|
||||
if not replay:
|
||||
self.upload_wait_phase = 'lane-capacity'
|
||||
if not await self.lane._reserve(len(body), wait=not replay):
|
||||
return False
|
||||
if self.closed:
|
||||
await self.lane._release(len(body))
|
||||
raise ConnectionError('H2 channel closed')
|
||||
replay_packet = self._find_packet(body) if replay else None
|
||||
now = time.monotonic()
|
||||
if replay and (replay_packet is None or replay_packet.pending is not None
|
||||
or not self._replay_due(replay_packet, now)):
|
||||
await self.lane._release(len(body))
|
||||
return False
|
||||
self.pending_bytes += len(body)
|
||||
self.requests += 1
|
||||
self.replay_requests += int(replay)
|
||||
self.up += len(body)
|
||||
stats.bytes_up += len(body)
|
||||
if not replay:
|
||||
self.last_native_send = now
|
||||
native_packet = self._remember_packet(body, now) if not replay else None
|
||||
if quick:
|
||||
self.quick_requests += 1
|
||||
packet = replay_packet or native_packet
|
||||
trace = (_RequestTrace(packet.sequence if packet is not None else self.requests,
|
||||
now, replay, len(body))
|
||||
if log.isEnabledFor(logging.DEBUG) else None)
|
||||
task = asyncio.create_task(self._run_request(body, replay, packet, trace))
|
||||
self.pending[task] = len(body)
|
||||
self.pending_since[task] = now
|
||||
if trace is not None:
|
||||
self.request_traces[task] = trace
|
||||
if native_packet is not None:
|
||||
native_packet.originals.add(task)
|
||||
self._trim_history(now)
|
||||
if replay:
|
||||
if not self._overdue_original(replay_packet, now):
|
||||
self.poll_requests += 1
|
||||
if not replay_packet.replayed:
|
||||
self.first_poll_gap = max(self.first_poll_gap, now - self.last_progress)
|
||||
self.replay_pending.add(task)
|
||||
replay_packet.attempts += 1
|
||||
replay_packet.last_attempt_at = now
|
||||
replay_packet.pending = task
|
||||
return True
|
||||
|
||||
def _find_packet(self, body: bytes) -> Optional[_ReplayPacket]:
|
||||
return next((entry for entry in self.replay_history if entry.body == body), None)
|
||||
|
||||
def _remember_packet(self, body: bytes, now: float) -> Optional[_ReplayPacket]:
|
||||
key = body[:8]
|
||||
if key == b'\x00' * 8:
|
||||
return None
|
||||
if self.replay_key != key:
|
||||
self.replay_history.clear()
|
||||
self.replay_key = key
|
||||
if len(body) > REPLAY_HISTORY_BYTES:
|
||||
return None
|
||||
packet = self._find_packet(body)
|
||||
if packet is None:
|
||||
packet = _ReplayPacket(now, body, self.requests)
|
||||
self.replay_history.append(packet)
|
||||
return packet
|
||||
|
||||
def _trim_history(self, now: float) -> None:
|
||||
retained_bytes = sum(len(entry.body) for entry in self.replay_history)
|
||||
while len(self.replay_history) > REPLAY_HISTORY_PACKETS or retained_bytes > REPLAY_HISTORY_BYTES:
|
||||
victim = next((entry for entry in self.replay_history if not entry.originals
|
||||
and not (entry.retired and entry.attempts < REPLAY_MAX_ATTEMPTS
|
||||
and now - entry.sent_at <= REPLAY_MAX_AGE)),
|
||||
self.replay_history[0])
|
||||
self.replay_history.remove(victim)
|
||||
retained_bytes -= len(victim.body)
|
||||
|
||||
async def _run_request(self, body: bytes, replay: bool, packet: Optional[_ReplayPacket],
|
||||
trace: Optional[_RequestTrace]) -> None:
|
||||
task = asyncio.current_task()
|
||||
try:
|
||||
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
|
||||
self.last_progress = time.monotonic()
|
||||
if reply and not self.closed:
|
||||
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
|
||||
raise BufferError('H2 client response queue exceeded bound')
|
||||
self.reply_bytes += len(reply)
|
||||
self.queue.put_nowait(reply)
|
||||
if trace is not None:
|
||||
trace.phase = 'complete'
|
||||
except asyncio.CancelledError:
|
||||
if trace is not None:
|
||||
trace.phase = 'cancelled-' + trace.phase
|
||||
raise
|
||||
except Exception as exc:
|
||||
if trace is not None:
|
||||
trace.phase = 'error-' + type(exc).__name__
|
||||
stats.h2_errors += 1
|
||||
if isinstance(exc, (httpx.HTTPError, OSError, ValueError, BufferError)):
|
||||
log.warning('[%s] H2 lane=%d channel=%d request failed: %s: %s; '
|
||||
'closing native channel, reconnect may select another domain',
|
||||
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc)
|
||||
else:
|
||||
log.exception('[%s] H2 lane=%d channel=%d unexpected request failure',
|
||||
self.label, self.lane.lane_id, self.channel_id)
|
||||
self._fail(exc)
|
||||
finally:
|
||||
await self._finish_request(task, packet)
|
||||
|
||||
async def _finish_request(self, task: asyncio.Task, packet: Optional[_ReplayPacket] = None) -> None:
|
||||
length = self.pending.pop(task, None)
|
||||
if length is None:
|
||||
return
|
||||
trace = self.request_traces.pop(task, None)
|
||||
if trace is not None:
|
||||
trace.finished_at = time.monotonic()
|
||||
self.recent_traces.append(trace)
|
||||
self.pending_bytes -= length
|
||||
self.pending_since.pop(task, None)
|
||||
self.sent_since.pop(task, None)
|
||||
self.replay_pending.discard(task)
|
||||
if packet is None:
|
||||
packet = next((entry for entry in self.replay_history
|
||||
if task in entry.originals or entry.pending is task), None)
|
||||
if packet is not None:
|
||||
packet.originals.discard(task)
|
||||
if packet.pending is task:
|
||||
packet.pending = None
|
||||
await self.lane._release(length)
|
||||
async with self.capacity:
|
||||
self.capacity.notify_all()
|
||||
self._wake_receiver()
|
||||
|
||||
async def _recover(self) -> None:
|
||||
while not self.closed:
|
||||
try:
|
||||
await asyncio.wait_for(self.recovery_wakeup.wait(), REPLAY_CHECK_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
self.recovery_wakeup.clear()
|
||||
now = time.monotonic()
|
||||
self.lane.log_wait(now)
|
||||
await self._make_upload_room(now)
|
||||
if self.closed or self.native_backpressure:
|
||||
continue
|
||||
def priority(entry):
|
||||
overdue = self._overdue_original(entry, now)
|
||||
return (not overdue, entry.sequence if overdue else -entry.sequence)
|
||||
|
||||
candidates = sorted(self.replay_history, key=priority)
|
||||
for entry in candidates:
|
||||
if not self._replay_due(entry, now):
|
||||
continue
|
||||
replace = entry.pending
|
||||
if replace is None and len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
|
||||
replace = next((task for task in self.pending if task in self.replay_pending
|
||||
and task not in self.receiving_tasks), None)
|
||||
if replace is None:
|
||||
continue
|
||||
if replace is not None:
|
||||
sent = self.sent_since.get(replace)
|
||||
if (replace in self.receiving_tasks
|
||||
or sent is None or now - sent < REPLAY_SLOT_SECONDS):
|
||||
continue
|
||||
replace.cancel()
|
||||
await asyncio.gather(replace, return_exceptions=True)
|
||||
self.replay_rotations += 1
|
||||
await self.send(entry.body, False, replay=True)
|
||||
break
|
||||
|
||||
async def receive(self) -> bytes:
|
||||
value = await self.queue.get()
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
self.reply_bytes -= len(value)
|
||||
self._wake_receiver()
|
||||
return value
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self.close_lock:
|
||||
if self.close_done:
|
||||
return
|
||||
waiting = self._wait_state(time.monotonic())
|
||||
if log.isEnabledFor(logging.DEBUG) and self.request_traces:
|
||||
now = time.monotonic()
|
||||
traces = sorted(self.request_traces.values(), key=lambda trace: trace.started)[:3]
|
||||
log.debug('H2 DETAIL CLOSE lane=%d [%s]', self.lane.lane_id,
|
||||
'; '.join(self._request_detail(trace, now) for trace in traces))
|
||||
self._fail(ConnectionError('H2 channel closed'))
|
||||
self.closed = True
|
||||
tasks = list(self.pending)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for task in list(self.pending):
|
||||
await self._finish_request(task)
|
||||
self.replay_pending.clear()
|
||||
self.replay_history.clear()
|
||||
self.sent_since.clear()
|
||||
self.request_traces.clear()
|
||||
self.recent_traces.clear()
|
||||
self.lane.channels.discard(self)
|
||||
self.close_done = True
|
||||
log.debug('[%s] H2 channel=%d closed lane=%d requests=%d replays=%d rotations=%d capacity_rotations=%d up=%d down=%d '
|
||||
'quickack=%d duration=%.1fs remaining_channels=%d; %s',
|
||||
self.label, self.channel_id, self.lane.lane_id, self.requests,
|
||||
self.replay_requests, self.replay_rotations, self.capacity_rotations, self.up, self.down, self.quick_requests,
|
||||
time.monotonic() - self.started, len(self.lane.channels), waiting)
|
||||
|
||||
class _HttpLane:
|
||||
def __init__(self, host: str, lane_id: int):
|
||||
self.host, self.lane_id = host, lane_id
|
||||
self.channels: Set[_HttpChannel] = set()
|
||||
self.failed_until = 0.0
|
||||
self.closed = False
|
||||
self.tcp_connections = self.requests = self.responses = self.errors = 0
|
||||
self.slow_headers = self.slow_bodies = self.replays = 0
|
||||
self.max_headers_seconds = self.max_body_seconds = 0.0
|
||||
self.next_wait_log = self.next_slow_log = 0.0
|
||||
self.next_capacity_log = 0.0
|
||||
self.capacity_rotations = 0
|
||||
self.next_large_slow_log = 0.0
|
||||
self.inflight = self.queued_bytes = self.max_inflight = 0
|
||||
self.reply_buffer_bytes = 0
|
||||
self.capacity = asyncio.Condition()
|
||||
self.client = httpx.AsyncClient(
|
||||
transport=H2Transport(create_ssl_context(), max_streams=MAX_LANE_REQUESTS),
|
||||
trust_env=False,
|
||||
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
|
||||
)
|
||||
|
||||
async def _trace(self, event: str, info: Dict) -> None:
|
||||
if event == 'connection.connect_tcp.started':
|
||||
self.tcp_connections += 1
|
||||
stats.h2_tcp_connections += 1
|
||||
log.debug('H2 lane=%d TCP_OPEN connection=%d host=%s channels=%d',
|
||||
self.lane_id, self.tcp_connections, self.host, len(self.channels))
|
||||
|
||||
async def _preflight(self) -> None:
|
||||
started = time.monotonic()
|
||||
async with self.client.stream('HEAD', 'https://' + self.host + '/api',
|
||||
extensions={'trace': self._trace}) as response:
|
||||
if response.http_version != 'HTTP/2':
|
||||
raise ValueError('CF /api preflight negotiated ' + response.http_version)
|
||||
if response.status_code in (403, 429) or (response.status_code >= 500
|
||||
and response.status_code != 501):
|
||||
raise ConnectionError('CF /api preflight HTTP %d cf_ray=%s' % (
|
||||
response.status_code, response.headers.get('cf-ray', '-')))
|
||||
log.debug('H2 lane=%d ready host=%s status=%d ms=%.0f',
|
||||
self.lane_id, self.host, response.status_code,
|
||||
(time.monotonic() - started) * 1000)
|
||||
|
||||
def _has_capacity(self, length: int) -> bool:
|
||||
return self.inflight < MAX_LANE_REQUESTS and self.queued_bytes + length <= MAX_LANE_BYTES
|
||||
|
||||
async def _reserve(self, length: int, wait: bool = True) -> bool:
|
||||
async with self.capacity:
|
||||
if not wait and not self._has_capacity(length):
|
||||
return False
|
||||
if wait:
|
||||
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(length)),
|
||||
REQUEST_TIMEOUT)
|
||||
if self.closed:
|
||||
raise ConnectionError('H2 lane closed')
|
||||
self.inflight += 1
|
||||
self.queued_bytes += length
|
||||
self.max_inflight = max(self.max_inflight, self.inflight)
|
||||
return True
|
||||
|
||||
async def _release(self, length: int) -> None:
|
||||
async with self.capacity:
|
||||
self.inflight -= 1
|
||||
self.queued_bytes -= length
|
||||
self.capacity.notify_all()
|
||||
|
||||
async def _post(self, body: bytes, channel_id: int, replay: bool = False,
|
||||
channel: Optional[_HttpChannel] = None) -> bytes:
|
||||
self.requests += 1
|
||||
self.replays += int(replay)
|
||||
stats.h2_requests += 1
|
||||
request_id = self.requests
|
||||
started = time.monotonic()
|
||||
buffered = 0
|
||||
receiving = False
|
||||
request_task = asyncio.current_task()
|
||||
trace = channel.request_traces.get(request_task) if channel is not None else None
|
||||
if trace is not None:
|
||||
trace.request = request_id
|
||||
trace.phase = 'connect-or-slot'
|
||||
|
||||
async def request_trace(event, info):
|
||||
await self._trace(event, info)
|
||||
if event == 'http2.send_request_body.complete' and channel is not None:
|
||||
channel.sent_since[request_task] = time.monotonic()
|
||||
if trace is not None:
|
||||
if event == 'connection.connect_tcp.started':
|
||||
trace.phase = 'connect'
|
||||
elif event == 'http2.wait_for_stream.started':
|
||||
trace.phase = 'stream-slot'
|
||||
elif event == 'http2.send_request_headers.started':
|
||||
trace.stream = info['stream_id']
|
||||
trace.phase = 'upload'
|
||||
elif event == 'http2.send_request_body.complete':
|
||||
trace.sent_at = time.monotonic()
|
||||
trace.phase = 'headers' if trace.headers_at is None else 'body'
|
||||
|
||||
def response_started():
|
||||
nonlocal receiving
|
||||
if trace is not None and trace.headers_at is None:
|
||||
trace.headers_at = time.monotonic()
|
||||
trace.phase = 'body'
|
||||
if channel is not None and not receiving:
|
||||
channel.receiving_tasks.add(request_task)
|
||||
channel.last_progress = time.monotonic()
|
||||
receiving = True
|
||||
|
||||
try:
|
||||
async with self.client.stream(
|
||||
'POST', 'https://' + self.host + '/api', content=body,
|
||||
headers={'content-type': 'application/octet-stream', 'accept-encoding': 'identity'},
|
||||
extensions={'trace': request_trace,
|
||||
'h2_response_started': response_started}) as response:
|
||||
headers_at = time.monotonic()
|
||||
if trace is not None:
|
||||
trace.cf_ray = ''.join(c for c in response.headers.get('cf-ray', '-')[:64]
|
||||
if c.isascii() and (c.isalnum() or c == '-'))
|
||||
self.slow_headers += int(headers_at - started >= SLOW_RESPONSE_SECONDS)
|
||||
self.max_headers_seconds = max(self.max_headers_seconds, headers_at - started)
|
||||
if response.http_version != 'HTTP/2':
|
||||
raise ValueError('CF /api downgraded to ' + response.http_version)
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError('CF /api HTTP %d cf_ray=%s' % (
|
||||
response.status_code, response.headers.get('cf-ray', '-')))
|
||||
if 'text/html' in response.headers.get('content-type', '').lower():
|
||||
raise ValueError('CF /api returned HTML instead of MTProto')
|
||||
response_started()
|
||||
length_header = response.headers.get('content-length')
|
||||
declared_length = int(length_header) if length_header is not None else None
|
||||
if declared_length is not None and not 0 <= declared_length <= MAX_PACKET:
|
||||
raise BufferError('H2 response Content-Length exceeds native packet bound')
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
if len(content) + len(chunk) > MAX_PACKET:
|
||||
raise BufferError('H2 response exceeds native packet bound')
|
||||
if self.reply_buffer_bytes + len(chunk) > MAX_LANE_REPLY_BYTES:
|
||||
raise _ReplyBufferFull('H2 lane response buffers exceeded bound')
|
||||
self.reply_buffer_bytes += len(chunk)
|
||||
buffered += len(chunk)
|
||||
content.extend(chunk)
|
||||
if trace is not None:
|
||||
trace.received += len(chunk)
|
||||
if channel is not None:
|
||||
channel.last_progress = time.monotonic()
|
||||
received = len(content)
|
||||
if (declared_length is not None
|
||||
and response.headers.get('content-encoding', 'identity') == 'identity'
|
||||
and received != declared_length):
|
||||
raise ValueError('incomplete H2 response body')
|
||||
if content and len(content) % 4:
|
||||
raise ValueError('CF /api response is not an aligned MTProto packet')
|
||||
if len(content) == 4:
|
||||
log.warning('H2 lane=%d channel=%d request=%d MTProto transport error=%d',
|
||||
self.lane_id, channel_id, request_id, struct.unpack('<i', content)[0])
|
||||
self.responses += 1
|
||||
if channel is not None:
|
||||
channel.http_bytes += received
|
||||
channel.small_responses += int(received <= 4096)
|
||||
channel.large_responses += int(received > 4096)
|
||||
finished = time.monotonic()
|
||||
body_seconds = finished - headers_at
|
||||
self.max_body_seconds = max(self.max_body_seconds, body_seconds)
|
||||
self.slow_bodies += int(body_seconds >= SLOW_RESPONSE_SECONDS)
|
||||
next_slow = self.next_large_slow_log if received > 4096 else self.next_slow_log
|
||||
if finished - started >= SLOW_RESPONSE_SECONDS and finished >= next_slow:
|
||||
if received > 4096:
|
||||
self.next_large_slow_log = finished + DIAGNOSTIC_INTERVAL
|
||||
else:
|
||||
self.next_slow_log = finished + DIAGNOSTIC_INTERVAL
|
||||
log.debug('H2 SLOW lane=%d channel=%d request=%d bytes=%d replay=%d '
|
||||
'headers_ms=%.0f body_ms=%.0f detail=[%s]',
|
||||
self.lane_id, channel_id, request_id, received, replay,
|
||||
(headers_at - started) * 1000, body_seconds * 1000,
|
||||
trace.summary(finished) if trace is not None else '-')
|
||||
return bytes(content)
|
||||
except _ReplyBufferFull:
|
||||
raise
|
||||
except (httpx.HTTPError, OSError, ValueError, BufferError):
|
||||
self.errors += 1
|
||||
self.failed_until = time.monotonic() + DOMAIN_COOLDOWN
|
||||
raise
|
||||
finally:
|
||||
self.reply_buffer_bytes -= buffered
|
||||
if receiving:
|
||||
channel.receiving_tasks.discard(request_task)
|
||||
|
||||
def log_wait(self, now: float) -> None:
|
||||
if not log.isEnabledFor(logging.DEBUG) or now < self.next_wait_log:
|
||||
return
|
||||
self.next_wait_log = now + 1.0
|
||||
waiting = [channel for channel in self.channels
|
||||
if ((channel.delivering_since is not None
|
||||
and now - channel.delivering_since >= SLOW_RESPONSE_SECONDS) or any(
|
||||
task not in channel.replay_pending
|
||||
and task not in channel.receiving_tasks
|
||||
and now - started >= SLOW_RESPONSE_SECONDS
|
||||
for task, started in channel.pending_since.items()))
|
||||
and now - channel.last_native_send <= REPLAY_MAX_AGE]
|
||||
if waiting:
|
||||
self.next_wait_log = now + DIAGNOSTIC_INTERVAL
|
||||
waiting.sort(key=lambda channel: channel.channel_id)
|
||||
log.debug('H2 WAIT lane=%d affected=%d inflight=%d body_buffer=%d [%s]',
|
||||
self.lane_id, len(waiting), self.inflight, self.reply_buffer_bytes,
|
||||
'; '.join(channel._wait_state(now) for channel in waiting[:8]))
|
||||
details = sorted(((channel, trace) for channel in waiting for trace in channel.request_traces.values()
|
||||
if not trace.replay and now - trace.started >= SLOW_RESPONSE_SECONDS),
|
||||
key=lambda pair: pair[1].started)[:12]
|
||||
if details:
|
||||
log.debug('H2 DETAIL lane=%d [%s]', self.lane_id,
|
||||
'; '.join(channel._request_detail(trace, now) for channel, trace in details))
|
||||
|
||||
def log_flow(self, now: float) -> None:
|
||||
if log.isEnabledFor(logging.DEBUG) and self.channels:
|
||||
channels = sorted(self.channels, key=lambda channel: channel.channel_id)
|
||||
for offset in range(0, len(channels), 8):
|
||||
log.debug('H2 FLOW lane=%d [%s]', self.lane_id,
|
||||
'; '.join(channel._flow_state(now) for channel in channels[offset:offset + 8]))
|
||||
|
||||
def summary(self) -> str:
|
||||
return ('lane=%d host=%s channels=%d tcp_connections=%d requests=%d replies=%d '
|
||||
'errors=%d inflight=%d max_inflight=%d queued_bytes=%d reply_buffer_bytes=%d '
|
||||
'replays=%d capacity_rotations=%d slow_headers=%d slow_bodies=%d max_headers_ms=%.0f max_body_ms=%.0f' % (
|
||||
self.lane_id, self.host, len(self.channels), self.tcp_connections,
|
||||
self.requests, self.responses, self.errors, self.inflight,
|
||||
self.max_inflight, self.queued_bytes, self.reply_buffer_bytes,
|
||||
self.replays, self.capacity_rotations, self.slow_headers, self.slow_bodies,
|
||||
self.max_headers_seconds * 1000, self.max_body_seconds * 1000))
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
async with self.capacity:
|
||||
self.capacity.notify_all()
|
||||
await asyncio.gather(*(channel.close() for channel in list(self.channels)))
|
||||
await self.client.aclose()
|
||||
log.debug('H2 CLOSED %s', self.summary())
|
||||
|
||||
|
||||
class CfH2Pool:
|
||||
def __init__(self):
|
||||
for name in ('httpx', 'httpcore', 'hpack', 'h2'):
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
self.lanes: Dict[str, _HttpLane] = {}
|
||||
self.locks: Dict[str, asyncio.Lock] = {}
|
||||
self.failed_until: Dict[str, float] = {}
|
||||
self.setup_retry_after: Dict[int, float] = {}
|
||||
self.next_lane = self.next_channel = 0
|
||||
self.closed = False
|
||||
|
||||
async def open(self, dc: int, label: str) -> Optional[_HttpChannel]:
|
||||
if self.closed:
|
||||
raise ConnectionError('H2 pool closed')
|
||||
if time.monotonic() < self.setup_retry_after.get(dc, 0):
|
||||
return None
|
||||
try:
|
||||
return await asyncio.wait_for(self._open(dc, label), SETUP_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
self.setup_retry_after[dc] = time.monotonic() + DOMAIN_COOLDOWN
|
||||
stats.h2_errors += 1
|
||||
log.warning('[%s] H2 DC%d setup timed out; using WS for %.0fs',
|
||||
label, dc, DOMAIN_COOLDOWN)
|
||||
return None
|
||||
|
||||
async def _open(self, dc: int, label: str) -> Optional[_HttpChannel]:
|
||||
if self.closed:
|
||||
raise ConnectionError('H2 pool closed')
|
||||
if dc not in DC_DEFAULT_IPS:
|
||||
log.warning('[%s] H2 unsupported DC%d -> existing WS route', label, dc)
|
||||
return None
|
||||
for base_domain in balancer.get_domains_for_dc(dc):
|
||||
host = 'kws%d.%s' % (dc, base_domain)
|
||||
async with self.locks.setdefault(host, asyncio.Lock()):
|
||||
if self.closed:
|
||||
raise ConnectionError('H2 pool closed')
|
||||
lane = self.lanes.get(host)
|
||||
if time.monotonic() < max(self.failed_until.get(host, 0),
|
||||
lane.failed_until if lane else 0):
|
||||
continue
|
||||
if lane is None:
|
||||
self.next_lane += 1
|
||||
lane = _HttpLane(host, self.next_lane)
|
||||
try:
|
||||
await lane._preflight()
|
||||
except (httpx.HTTPError, OSError, ValueError) as exc:
|
||||
stats.h2_errors += 1
|
||||
self.failed_until[host] = time.monotonic() + DOMAIN_COOLDOWN
|
||||
log.warning('[%s] H2 lane=%d preflight failed host=%s: %s: %s',
|
||||
label, lane.lane_id, host, type(exc).__name__, exc)
|
||||
await lane.close()
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
await lane.close()
|
||||
raise
|
||||
if self.closed:
|
||||
await lane.close()
|
||||
raise ConnectionError('H2 pool closed during preflight')
|
||||
self.lanes[host] = lane
|
||||
self.next_channel += 1
|
||||
channel = _HttpChannel(lane, self.next_channel, label)
|
||||
balancer.update_domain_for_dc(dc, base_domain)
|
||||
log.info('[%s] ROUTE DC%d media -> H2 lane=%d channel=%d host=%s '
|
||||
'channels=%d tcp_connections=%d', label, dc, lane.lane_id,
|
||||
channel.channel_id, host, len(lane.channels), lane.tcp_connections)
|
||||
return channel
|
||||
log.debug('[%s] H2 DC%d unavailable; using WS', label, dc)
|
||||
return None
|
||||
|
||||
def log_stats(self) -> None:
|
||||
for lane in self.lanes.values():
|
||||
if lane.channels:
|
||||
log.debug('H2 STATS %s', lane.summary())
|
||||
|
||||
def log_flow(self, now: float) -> None:
|
||||
for lane in self.lanes.values():
|
||||
lane.log_flow(now)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
await asyncio.gather(*(lane.close() for lane in self.lanes.values()))
|
||||
self.lanes.clear()
|
||||
|
||||
|
||||
async def bridge_h2(reader, writer, channel: _HttpChannel, ctx, tag: bytes) -> None:
|
||||
async def write_native(data):
|
||||
try:
|
||||
writer.write(ctx.clt_enc.update(data))
|
||||
await writer.drain()
|
||||
except OSError as exc:
|
||||
raise _NativeDeliveryError(str(exc)) from exc
|
||||
|
||||
async def upload() -> None:
|
||||
while True:
|
||||
try:
|
||||
body, quick = await _read_packet(reader, ctx.clt_dec, tag)
|
||||
except OSError as exc:
|
||||
raise _NativeDeliveryError(str(exc)) from exc
|
||||
await channel.send(body, quick)
|
||||
|
||||
async def download() -> None:
|
||||
while True:
|
||||
body = await channel.receive()
|
||||
if channel.closed:
|
||||
raise _NativeDeliveryError('H2 channel closed before native response')
|
||||
channel.delivering_since = time.monotonic()
|
||||
try:
|
||||
await write_native(_encode_reply(body, tag))
|
||||
finally:
|
||||
channel.delivering_since = None
|
||||
channel.down += len(body)
|
||||
stats.bytes_down += len(body)
|
||||
channel.last_progress = time.monotonic()
|
||||
channel._wake_receiver()
|
||||
|
||||
tasks = [asyncio.create_task(upload()), asyncio.create_task(download()),
|
||||
asyncio.create_task(channel._recover())]
|
||||
try:
|
||||
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in done:
|
||||
task.result()
|
||||
except asyncio.IncompleteReadError:
|
||||
log.debug('[%s] H2 channel=%d native EOF', channel.label, channel.channel_id)
|
||||
except _NativeDeliveryError as exc:
|
||||
log.debug('[%s] H2 channel=%d native closed: %s', channel.label, channel.channel_id, exc)
|
||||
except (OSError, ValueError, BufferError, asyncio.TimeoutError) as exc:
|
||||
if not channel.closed:
|
||||
log.warning('[%s] H2 channel=%d bridge ended: %s: %s',
|
||||
channel.label, channel.channel_id, type(exc).__name__, exc)
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await channel.close()
|
||||
@@ -70,10 +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()
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import httpx
|
||||
from h2.config import H2Configuration
|
||||
from h2.connection import H2Connection
|
||||
from h2.errors import ErrorCodes
|
||||
from h2.events import (
|
||||
ConnectionTerminated, DataReceived, RemoteSettingsChanged,
|
||||
ResponseReceived, StreamEnded, StreamReset, WindowUpdated,
|
||||
)
|
||||
from h2.exceptions import H2Error, StreamClosedError
|
||||
from h2.settings import SettingCodes, Settings
|
||||
|
||||
STREAM_RECEIVE_WINDOW = 256 * 1024
|
||||
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class _ResponseStream(httpx.AsyncByteStream):
|
||||
def __init__(self, connection, stream_id, read_timeout):
|
||||
self.connection = connection
|
||||
self.stream_id = stream_id
|
||||
self.read_timeout = read_timeout
|
||||
self.changed = asyncio.Event()
|
||||
self.chunks = deque()
|
||||
self.headers = None
|
||||
self.response_started = None
|
||||
self.error = None
|
||||
self.ended = False
|
||||
self.closed = False
|
||||
self.extensions = {'http_version': b'HTTP/2', 'stream_id': stream_id}
|
||||
|
||||
async def wait(self):
|
||||
try:
|
||||
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id) from exc
|
||||
|
||||
def check_error(self):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
async def __aiter__(self):
|
||||
while not self.closed:
|
||||
self.changed.clear()
|
||||
self.check_error()
|
||||
if self.chunks:
|
||||
data, credit = self.chunks.popleft()
|
||||
yield data
|
||||
self.connection.consumed(self, credit)
|
||||
elif self.ended:
|
||||
return
|
||||
else:
|
||||
await self.wait()
|
||||
|
||||
async def aclose(self):
|
||||
if not self.closed:
|
||||
self.closed = True
|
||||
self.chunks.clear()
|
||||
self.connection.release(self)
|
||||
self.changed.set()
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, reader, writer, max_streams, write_timeout):
|
||||
self.reader, self.writer = reader, writer
|
||||
self.max_streams = max_streams
|
||||
self.write_timeout = write_timeout
|
||||
self.h2 = H2Connection(config=H2Configuration(client_side=True))
|
||||
self.h2.local_settings = Settings(
|
||||
client=True, initial_values={SettingCodes.ENABLE_PUSH: 0,
|
||||
SettingCodes.INITIAL_WINDOW_SIZE: STREAM_RECEIVE_WINDOW})
|
||||
self.streams = {}
|
||||
self.changed = asyncio.Event()
|
||||
self.write_lock = asyncio.Lock()
|
||||
self.settings_received = False
|
||||
self.error = None
|
||||
self.h2.initiate_connection()
|
||||
self.h2.increment_flow_control_window(CONNECTION_RECEIVE_WINDOW - 65535)
|
||||
self.flush()
|
||||
self.reader_task = asyncio.create_task(self.read_loop())
|
||||
|
||||
def get_extra_info(self, name):
|
||||
return self.writer.get_extra_info(name)
|
||||
|
||||
def flush(self):
|
||||
data = self.h2.data_to_send()
|
||||
if data:
|
||||
self.writer.write(data)
|
||||
|
||||
async def drain(self):
|
||||
try:
|
||||
async with self.write_lock:
|
||||
await asyncio.wait_for(self.writer.drain(), self.write_timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
error = httpx.WriteTimeout('H2 connection write timed out')
|
||||
self.fail(error)
|
||||
raise error from exc
|
||||
except OSError as exc:
|
||||
error = httpx.WriteError(str(exc))
|
||||
self.fail(error)
|
||||
raise error from exc
|
||||
|
||||
def fail(self, error):
|
||||
if self.error is None:
|
||||
self.error = error
|
||||
for stream in self.streams.values():
|
||||
if not stream.ended:
|
||||
stream.error = error
|
||||
stream.changed.set()
|
||||
self.changed.set()
|
||||
self.writer.close()
|
||||
|
||||
async def read_loop(self):
|
||||
try:
|
||||
while self.error is None:
|
||||
data = await self.reader.read(64 * 1024)
|
||||
if not data:
|
||||
raise httpx.ReadError('H2 peer closed the connection')
|
||||
events = self.h2.receive_data(data)
|
||||
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
|
||||
for event in events:
|
||||
if isinstance(event, RemoteSettingsChanged):
|
||||
self.settings_received = True
|
||||
self.changed.set()
|
||||
elif isinstance(event, WindowUpdated):
|
||||
self.changed.set()
|
||||
elif isinstance(event, ConnectionTerminated):
|
||||
raise httpx.RemoteProtocolError('H2 GOAWAY error=%s last_stream=%s' % (
|
||||
event.error_code, event.last_stream_id))
|
||||
else:
|
||||
stream = self.streams.get(getattr(event, 'stream_id', None))
|
||||
if isinstance(event, DataReceived):
|
||||
if event.flow_controlled_length and not terminated:
|
||||
self.h2.increment_flow_control_window(event.flow_controlled_length)
|
||||
if stream is not None:
|
||||
if event.data:
|
||||
stream.chunks.append((event.data, event.flow_controlled_length))
|
||||
elif not terminated:
|
||||
self.consumed(stream, event.flow_controlled_length)
|
||||
elif stream is not None:
|
||||
if isinstance(event, ResponseReceived):
|
||||
stream.headers = event.headers
|
||||
if stream.response_started is not None:
|
||||
stream.response_started()
|
||||
elif isinstance(event, StreamEnded):
|
||||
stream.ended = True
|
||||
elif isinstance(event, StreamReset):
|
||||
stream.error = httpx.RemoteProtocolError(
|
||||
'H2 stream %d reset: %s' % (event.stream_id, event.error_code))
|
||||
self.changed.set()
|
||||
if stream is not None:
|
||||
stream.changed.set()
|
||||
self.flush()
|
||||
await self.drain()
|
||||
except asyncio.CancelledError:
|
||||
self.fail(httpx.ReadError('H2 reader stopped'))
|
||||
raise
|
||||
except (OSError, H2Error, httpx.HTTPError) as exc:
|
||||
error = exc if isinstance(exc, httpx.HTTPError) else httpx.RemoteProtocolError(str(exc))
|
||||
self.fail(error)
|
||||
|
||||
def check_error(self):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
async def reserve(self, timeouts):
|
||||
timeout = timeouts.get('pool')
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
self.changed.clear()
|
||||
self.check_error()
|
||||
if self.settings_received and len(self.streams) < min(
|
||||
self.max_streams, self.h2.remote_settings.max_concurrent_streams):
|
||||
stream_id = self.h2.get_next_available_stream_id()
|
||||
stream = _ResponseStream(self, stream_id, timeouts.get('read'))
|
||||
self.streams[stream_id] = stream
|
||||
return stream
|
||||
remaining = None if deadline is None else max(0, deadline - time.monotonic())
|
||||
try:
|
||||
await asyncio.wait_for(self.changed.wait(), remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise httpx.PoolTimeout('H2 stream capacity exhausted') from exc
|
||||
|
||||
async def send_body(self, request, stream):
|
||||
async for data in request.stream:
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
self.changed.clear()
|
||||
self.check_error()
|
||||
stream.check_error()
|
||||
count = min(len(data) - offset, self.h2.max_outbound_frame_size,
|
||||
self.h2.local_flow_control_window(stream.stream_id))
|
||||
if count <= 0:
|
||||
try:
|
||||
await asyncio.wait_for(self.changed.wait(), self.write_timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
|
||||
continue
|
||||
self.h2.send_data(stream.stream_id, data[offset:offset + count])
|
||||
offset += count
|
||||
self.flush()
|
||||
await self.drain()
|
||||
self.h2.end_stream(stream.stream_id)
|
||||
self.flush()
|
||||
await self.drain()
|
||||
|
||||
async def request(self, request):
|
||||
timeouts = request.extensions.get('timeout', {})
|
||||
trace = request.extensions.get('trace')
|
||||
if trace:
|
||||
await trace('http2.wait_for_stream.started', {})
|
||||
stream = await self.reserve(timeouts)
|
||||
stream.response_started = request.extensions.get('h2_response_started')
|
||||
stream_id = stream.stream_id
|
||||
try:
|
||||
headers = [(b':method', request.method.encode('ascii')),
|
||||
(b':scheme', request.url.raw_scheme),
|
||||
(b':authority', request.headers['host'].encode('ascii')),
|
||||
(b':path', request.url.raw_path)]
|
||||
headers.extend((k.lower(), v) for k, v in request.headers.raw if k.lower() not in (
|
||||
b'host', b'connection', b'keep-alive', b'proxy-connection',
|
||||
b'upgrade', b'transfer-encoding'))
|
||||
self.h2.send_headers(stream_id, headers)
|
||||
self.flush()
|
||||
if trace:
|
||||
await trace('http2.send_request_headers.started', {'stream_id': stream_id})
|
||||
await self.send_body(request, stream)
|
||||
if trace:
|
||||
await trace('http2.send_request_body.complete', {'stream_id': stream_id})
|
||||
while stream.headers is None:
|
||||
stream.changed.clear()
|
||||
stream.check_error()
|
||||
await stream.wait()
|
||||
stream.check_error()
|
||||
status = next(int(v) for k, v in stream.headers if k == b':status')
|
||||
return httpx.Response(status, headers=[(k, v) for k, v in stream.headers
|
||||
if not k.startswith(b':')],
|
||||
stream=stream, extensions=stream.extensions)
|
||||
except H2Error as exc:
|
||||
await stream.aclose()
|
||||
raise httpx.LocalProtocolError(str(exc)) from exc
|
||||
except BaseException:
|
||||
await stream.aclose()
|
||||
raise
|
||||
|
||||
def consumed(self, stream, credit):
|
||||
if credit and not stream.ended and not stream.closed and self.error is None:
|
||||
try:
|
||||
self.h2.increment_flow_control_window(credit, stream.stream_id)
|
||||
self.flush()
|
||||
except StreamClosedError:
|
||||
pass
|
||||
|
||||
def release(self, stream):
|
||||
self.streams.pop(stream.stream_id, None)
|
||||
if self.error is None:
|
||||
try:
|
||||
self.h2.reset_stream(stream.stream_id, ErrorCodes.CANCEL)
|
||||
self.flush()
|
||||
except StreamClosedError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
self.fail(httpx.WriteError(str(exc)))
|
||||
self.changed.set()
|
||||
|
||||
async def aclose(self):
|
||||
self.fail(httpx.ReadError('H2 connection closed'))
|
||||
self.reader_task.cancel()
|
||||
await asyncio.gather(self.reader_task, return_exceptions=True)
|
||||
try:
|
||||
await asyncio.wait_for(self.writer.wait_closed(), 2)
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class H2Transport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, ssl_context, max_streams=64):
|
||||
self.ssl_context = ssl_context
|
||||
self.ssl_context.set_alpn_protocols(['h2'])
|
||||
self.max_streams = max_streams
|
||||
self.connection = None
|
||||
self.origin = None
|
||||
self.connect_lock = asyncio.Lock()
|
||||
self.closed = False
|
||||
|
||||
async def handle_async_request(self, request):
|
||||
origin = (request.url.host, request.url.port or 443)
|
||||
if request.url.scheme != 'https' or self.origin not in (None, origin):
|
||||
raise httpx.UnsupportedProtocol('CF H2 transport requires a single HTTPS origin')
|
||||
async with self.connect_lock:
|
||||
if self.closed:
|
||||
raise httpx.ConnectError('H2 transport closed')
|
||||
self.origin = origin
|
||||
if self.connection is None or self.connection.error is not None:
|
||||
if self.connection is not None:
|
||||
await self.connection.aclose()
|
||||
timeouts = request.extensions.get('timeout', {})
|
||||
trace = request.extensions.get('trace')
|
||||
if trace:
|
||||
await trace('connection.connect_tcp.started', {})
|
||||
writer = None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection(
|
||||
*origin, ssl=self.ssl_context, server_hostname=origin[0]),
|
||||
timeouts.get('connect'))
|
||||
if writer.get_extra_info('ssl_object').selected_alpn_protocol() != 'h2':
|
||||
raise httpx.RemoteProtocolError('CF origin did not negotiate h2')
|
||||
self.connection = _Connection(reader, writer, self.max_streams, timeouts.get('write'))
|
||||
if trace:
|
||||
await trace('connection.start_tls.complete', {'return_value': self.connection})
|
||||
except BaseException as exc:
|
||||
if self.connection is not None:
|
||||
self.connection.fail(httpx.ConnectError('H2 connection setup interrupted'))
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
if isinstance(exc, asyncio.TimeoutError):
|
||||
raise httpx.ConnectTimeout('H2 TLS connect timed out') from exc
|
||||
if isinstance(exc, OSError):
|
||||
raise httpx.ConnectError(str(exc)) from exc
|
||||
raise
|
||||
connection = self.connection
|
||||
return await connection.request(request)
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
async with self.connect_lock:
|
||||
if self.connection is not None:
|
||||
await self.connection.aclose()
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
class WsActivity:
|
||||
active = set()
|
||||
|
||||
def __init__(self, label, dc):
|
||||
self.label, self.dc = label, dc
|
||||
self.started = time.monotonic()
|
||||
self.native_up = self.ws_up = self.ws_down = self.native_down = 0
|
||||
self.first_input = self.first_reply = self.last_rx = self.last_tx = None
|
||||
self.awaiting_rx = self.sending = self.writing_native = None
|
||||
self.max_rx_gap = self.max_send = self.max_native_write = 0.0
|
||||
self.sample = (self.started, 0, 0, 0, 0)
|
||||
self.active.add(self)
|
||||
log.debug('[%s] WS OPEN %s', self.label, self.dc)
|
||||
|
||||
def input(self, length):
|
||||
self.native_up += length
|
||||
if self.first_input is None:
|
||||
self.first_input = time.monotonic()
|
||||
|
||||
def send_started(self):
|
||||
self.sending = time.monotonic()
|
||||
|
||||
def sent(self, length):
|
||||
now = time.monotonic()
|
||||
self.ws_up += length
|
||||
if self.sending is not None:
|
||||
self.max_send = max(self.max_send, now - self.sending)
|
||||
if self.awaiting_rx is None and (self.last_rx is None or self.last_rx < self.sending):
|
||||
self.awaiting_rx = self.sending
|
||||
self.last_tx, self.sending = now, None
|
||||
|
||||
def received(self, length):
|
||||
now = time.monotonic()
|
||||
self.ws_down += length
|
||||
self.last_rx = now
|
||||
if self.awaiting_rx is not None:
|
||||
self.max_rx_gap = max(self.max_rx_gap, now - self.awaiting_rx)
|
||||
if self.first_reply is None and self.first_input is not None:
|
||||
self.first_reply = now - self.first_input
|
||||
self.awaiting_rx = None
|
||||
self.writing_native = now
|
||||
|
||||
def delivered(self, length):
|
||||
self.native_down += length
|
||||
if self.writing_native is not None:
|
||||
self.max_native_write = max(self.max_native_write, time.monotonic() - self.writing_native)
|
||||
self.writing_native = None
|
||||
|
||||
def summary(self, now):
|
||||
current = (now, self.native_up, self.ws_up, self.ws_down, self.native_down)
|
||||
previous, self.sample = self.sample, current
|
||||
return ('client=%s window_ms=%.0f native_up=%d ws_up=%d ws_down=%d native_down=%d '
|
||||
'rx_gap_ms=%.0f last_rx_ms=%.0f ws_write_ms=%.0f native_write_ms=%.0f' % (
|
||||
self.label, (now - previous[0]) * 1000,
|
||||
*(current[index] - previous[index] for index in range(1, 5)),
|
||||
0 if self.awaiting_rx is None else (now - self.awaiting_rx) * 1000,
|
||||
-1 if self.last_rx is None else (now - self.last_rx) * 1000,
|
||||
0 if self.sending is None else (now - self.sending) * 1000,
|
||||
0 if self.writing_native is None else (now - self.writing_native) * 1000))
|
||||
|
||||
def close(self):
|
||||
now = time.monotonic()
|
||||
self.active.discard(self)
|
||||
log.debug('WS END %s %s first_reply_ms=%.0f max_rx_gap_ms=%.0f '
|
||||
'max_ws_write_ms=%.0f max_native_write_ms=%.0f',
|
||||
self.dc, self.summary(now),
|
||||
-1 if self.first_reply is None else self.first_reply * 1000,
|
||||
self.max_rx_gap * 1000, self.max_send * 1000, self.max_native_write * 1000)
|
||||
|
||||
|
||||
def log_ws_flow(now):
|
||||
if not log.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
groups = defaultdict(list)
|
||||
for activity in WsActivity.active:
|
||||
groups[activity.dc].append(activity)
|
||||
for dc, activities in sorted(groups.items()):
|
||||
activities.sort(key=lambda item: item.label)
|
||||
for offset in range(0, len(activities), 8):
|
||||
log.debug('WS FLOW %s [%s]', dc,
|
||||
'; '.join(item.summary(now) for item in activities[offset:offset + 8]))
|
||||
+106
-91
@@ -10,29 +10,32 @@ from typing import Dict, List, Optional, Tuple, Set
|
||||
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||
from .stats import stats
|
||||
from .config import proxy_config
|
||||
from .utils import ws_domains, DC_DEFAULT_IPS
|
||||
from .utils import ws_domains, DC_DEFAULT_IPS, WS_PATH, WS_PATH_TEST
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
class _WsPool:
|
||||
WS_POOL_MAX_AGE = 120.0
|
||||
WS_POOL_CHECK_INTERVAL = 5.0
|
||||
REFILL_BACKOFF_INITIAL = 60.0
|
||||
REFILL_BACKOFF_INITIAL = 1.0
|
||||
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._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],
|
||||
*, allow_refill: bool = True
|
||||
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)
|
||||
@@ -42,37 +45,38 @@ 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)
|
||||
if allow_refill:
|
||||
self._schedule_refill(key, target_ip, domains)
|
||||
log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
|
||||
dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
|
||||
self._schedule_refill(key, target_ip, domains)
|
||||
return ws
|
||||
|
||||
stats.pool_misses += 1
|
||||
if allow_refill:
|
||||
self._schedule_refill(key, target_ip, domains)
|
||||
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)
|
||||
@@ -80,35 +84,47 @@ 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
|
||||
delay = min(
|
||||
self.REFILL_BACKOFF_INITIAL
|
||||
* (2 ** min(failures - 1, 6)),
|
||||
* (2 ** min(failures - 1, 12)),
|
||||
self.REFILL_BACKOFF_MAX,
|
||||
)
|
||||
self._refill_after[key] = time.monotonic() + delay
|
||||
log.info(
|
||||
"WS pool refill failed for DC%d%s, 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:
|
||||
@@ -117,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))
|
||||
@@ -148,47 +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:
|
||||
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()
|
||||
@@ -201,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()
|
||||
@@ -216,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
|
||||
@@ -288,7 +302,8 @@ class _CfWorkerPool:
|
||||
for worker_domain in self.available_domains(worker_domains):
|
||||
try:
|
||||
ws = await RawWebSocket.connect(
|
||||
worker_domain, worker_domain, timeout=8, path=path)
|
||||
worker_domain, worker_domain, timeout=8, path=path,
|
||||
secure=not proxy_config.disable_secure)
|
||||
return ws, worker_domain
|
||||
except Exception as exc:
|
||||
self.report_failure(worker_domain, exc)
|
||||
|
||||
+58
-52
@@ -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,10 +21,8 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
|
||||
_st_H = struct.Struct('>H')
|
||||
_st_Q = struct.Struct('>Q')
|
||||
|
||||
_ssl_ctx = ssl.create_default_context()
|
||||
_ssl_ctx.check_hostname = False
|
||||
_ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
_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,
|
||||
@@ -87,35 +85,45 @@ class RawWebSocket:
|
||||
@staticmethod
|
||||
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||
path: str = '/apiws', *,
|
||||
sni: Optional[str] = None) -> 'RawWebSocket':
|
||||
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
|
||||
ssl_context = _ssl_ctx_fronting if sni else _ssl_ctx
|
||||
|
||||
if sni is None:
|
||||
sni = domain
|
||||
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||
server_hostname=sni),
|
||||
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'
|
||||
(
|
||||
asyncio.open_connection(
|
||||
host, 443,
|
||||
ssl=ssl_context,
|
||||
server_hostname=sni,
|
||||
)
|
||||
if secure
|
||||
else asyncio.open_connection(host, 80)
|
||||
),
|
||||
timeout=min(timeout, 10),
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -123,34 +131,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")
|
||||
|
||||
@@ -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} "
|
||||
|
||||
+82
-140
@@ -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,21 @@ 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 ._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,111 +292,11 @@ 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
|
||||
|
||||
# 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:
|
||||
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
|
||||
label, dc, media_tag, target)
|
||||
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
|
||||
|
||||
domains = ws_domains(dc, is_media)
|
||||
ws = None
|
||||
ws_failed_redirect = False
|
||||
ws_timed_out = False
|
||||
all_redirects = True
|
||||
|
||||
allow_pool_refill = now >= ip_fail_until.get(target, 0)
|
||||
ws = await ws_pool.get(
|
||||
dc, is_media, target, domains,
|
||||
allow_refill=allow_pool_refill,
|
||||
) if not is_test_dc else None
|
||||
if ws:
|
||||
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 cooldown 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)
|
||||
@@ -409,15 +305,14 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
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
|
||||
|
||||
dc_fail_until.pop(dc_key, None)
|
||||
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
|
||||
@@ -464,15 +359,14 @@ _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
|
||||
_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:
|
||||
@@ -481,8 +375,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)
|
||||
@@ -524,6 +422,10 @@ 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)
|
||||
log.info(" Connect:")
|
||||
if ftls:
|
||||
@@ -533,11 +435,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
|
||||
|
||||
@@ -554,6 +470,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())
|
||||
@@ -576,19 +493,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()
|
||||
@@ -613,6 +523,21 @@ 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()
|
||||
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
|
||||
@@ -640,7 +565,7 @@ def main():
|
||||
ap.add_argument('--secret', type=str, default=None,
|
||||
help='MTProto proxy secret (32 hex chars). '
|
||||
'Auto-generated if not provided.')
|
||||
ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
|
||||
ap.add_argument('--dc-ip', metavar='DC:IP', nargs='?', action='append', const=None,
|
||||
help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Debug logging')
|
||||
@@ -654,18 +579,24 @@ 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',
|
||||
help='Use 80 port for CF-proxy and CF-worker connections')
|
||||
ap.add_argument('--fake-tls-domain', type=str, default='',
|
||||
metavar='DOMAIN',
|
||||
help='Enable Fake TLS (ee-secret) masking with the given '
|
||||
@@ -680,8 +611,13 @@ def main():
|
||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.dc_ip:
|
||||
args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220']
|
||||
if args.dc_ip is None:
|
||||
args.dc_ip = [
|
||||
'2:149.154.167.220',
|
||||
'4:149.154.167.220',
|
||||
]
|
||||
elif None in args.dc_ip:
|
||||
args.dc_ip = []
|
||||
|
||||
try:
|
||||
dc_redirects = parse_dc_ip_list(args.dc_ip)
|
||||
@@ -712,6 +648,8 @@ 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
|
||||
proxy_config.force_test_dc = args.force_test_dc
|
||||
@@ -724,6 +662,7 @@ def main():
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(log_fmt)
|
||||
console.addFilter(DomainCensorFilter())
|
||||
root.addHandler(console)
|
||||
|
||||
if args.log_file:
|
||||
@@ -734,9 +673,12 @@ def main():
|
||||
backups=args.log_backups,
|
||||
)
|
||||
fh.setFormatter(log_fmt)
|
||||
fh.addFilter(DomainCensorFilter())
|
||||
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())
|
||||
|
||||
+41
-6
@@ -2,6 +2,8 @@ import socket as _socket
|
||||
import urllib.request
|
||||
import http.client
|
||||
import ssl
|
||||
import logging
|
||||
import re
|
||||
|
||||
import certifi
|
||||
|
||||
@@ -56,12 +58,12 @@ 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 is_media is None or is_media:
|
||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
||||
if not is_media:
|
||||
return [f'kws{dc}.web.telegram.org']
|
||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||
|
||||
|
||||
def human_bytes(n: int) -> str:
|
||||
@@ -85,6 +87,32 @@ def get_link_host(host: str) -> Optional[str]:
|
||||
return host
|
||||
|
||||
|
||||
class DomainCensorFilter(logging.Filter):
|
||||
domain_pattern = re.compile(
|
||||
r'(?<![\w-])(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+'
|
||||
r'[a-zA-Z]{2,}(?![\w-])'
|
||||
)
|
||||
|
||||
def _censor_match(self, match):
|
||||
domain = match.group()
|
||||
normalized = domain.casefold().rstrip('.')
|
||||
if normalized == 'telegram.org' or normalized.endswith('.telegram.org') or normalized.endswith('.log'):
|
||||
return domain
|
||||
parts = domain.split('.')
|
||||
if len(parts) < 2:
|
||||
return domain
|
||||
return '.'.join(
|
||||
part if i == len(parts) - 1 else
|
||||
part[:len(part) // 2] + '*' * (len(part) - len(part) // 2)
|
||||
for i, part in enumerate(parts)
|
||||
)
|
||||
|
||||
def filter(self, record):
|
||||
record.msg = self.domain_pattern.sub(self._censor_match, record.getMessage())
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req: Request):
|
||||
host = req.host.split(":")[0]
|
||||
@@ -112,6 +140,13 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
return super().https_open(req)
|
||||
|
||||
|
||||
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
return urllib.request.build_opener(_PinnedHTTPSHandler(context=context))
|
||||
context.load_default_certs()
|
||||
context.check_hostname = check_hostname
|
||||
return context
|
||||
|
||||
|
||||
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||
return urllib.request.build_opener(
|
||||
_PinnedHTTPSHandler(context=create_ssl_context()))
|
||||
@@ -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'",
|
||||
|
||||
+90
-1
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
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,
|
||||
@@ -112,5 +116,90 @@ class MsgSplitterTest(unittest.TestCase):
|
||||
self.assertEqual(splitter.flush(), [])
|
||||
|
||||
|
||||
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
|
||||
class Cipher:
|
||||
def update(self, data):
|
||||
return data
|
||||
|
||||
class Socket:
|
||||
def __init__(self):
|
||||
self.queue = asyncio.Queue()
|
||||
self.sending = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
self.sent = []
|
||||
|
||||
async def send(self, data):
|
||||
self.sent.append(data)
|
||||
self.sending.set()
|
||||
await self.release.wait()
|
||||
|
||||
async def recv(self):
|
||||
return await self.queue.get()
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
class Writer:
|
||||
def __init__(self):
|
||||
self.data = bytearray()
|
||||
self.written = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
def write(self, data):
|
||||
self.data.extend(data)
|
||||
self.written.set()
|
||||
|
||||
async def drain(self):
|
||||
await self.release.wait()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
async def wait_closed(self):
|
||||
pass
|
||||
|
||||
ws, writer = Socket(), Writer()
|
||||
reader = asyncio.StreamReader()
|
||||
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
|
||||
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
|
||||
try:
|
||||
reader.feed_data(b'private-native-payload')
|
||||
await asyncio.wait_for(ws.sending.wait(), 1)
|
||||
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
|
||||
self.assertIsNotNone(activity.sending)
|
||||
self.assertEqual(activity.ws_up, 0)
|
||||
log_ws_flow(time.monotonic())
|
||||
# A reply arriving before send drain finishes must not create
|
||||
# a spurious wait for another response when drain resumes.
|
||||
await ws.queue.put(b'private-upstream-payload')
|
||||
await asyncio.wait_for(writer.written.wait(), 1)
|
||||
self.assertEqual(activity.ws_down, len(writer.data))
|
||||
self.assertEqual(activity.native_down, 0)
|
||||
self.assertIsNotNone(activity.writing_native)
|
||||
log_ws_flow(time.monotonic())
|
||||
ws.release.set()
|
||||
writer.release.set()
|
||||
for _ in range(100):
|
||||
if activity.native_down and activity.ws_up:
|
||||
break
|
||||
await asyncio.sleep(.001)
|
||||
self.assertEqual(activity.native_down, len(writer.data))
|
||||
self.assertIsNone(activity.awaiting_rx)
|
||||
self.assertEqual(ws.sent, [b'private-native-payload'])
|
||||
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
|
||||
reader.feed_eof()
|
||||
await asyncio.wait_for(bridge, 1)
|
||||
finally:
|
||||
bridge.cancel()
|
||||
await asyncio.gather(bridge, return_exceptions=True)
|
||||
self.assertFalse(WsActivity.active)
|
||||
output = '\n'.join(captured.output)
|
||||
self.assertIn('WS FLOW DC203', output)
|
||||
self.assertIn('WS END DC203', output)
|
||||
self.assertNotIn('private-', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,912 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import struct
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
from proxy.balancer import _Balancer
|
||||
from proxy.cf_h2 import (
|
||||
CfH2Pool, MAX_CHANNEL_REQUESTS, MAX_PACKET, _HttpChannel, _HttpLane,
|
||||
_ReplayPacket, _encode_reply, _read_packet, bridge_h2,
|
||||
)
|
||||
from proxy.utils import PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE
|
||||
|
||||
class _IdentityCipher:
|
||||
def update(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class NativeFramingTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _packet(self, wire, tag):
|
||||
reader = asyncio.StreamReader()
|
||||
async def feed():
|
||||
for byte in wire:
|
||||
reader.feed_data(bytes([byte]))
|
||||
await asyncio.sleep(0)
|
||||
reader.feed_eof()
|
||||
task = asyncio.create_task(feed())
|
||||
try:
|
||||
return await _read_packet(reader, _IdentityCipher(), tag)
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
async def test_fragmented_abridged_extended_and_quick_ack(self):
|
||||
body = b'a' * 512
|
||||
decoded, quick = await self._packet(b'\xff\x80\x00\x00' + body, PROTO_TAG_ABRIDGED)
|
||||
self.assertEqual(decoded, body)
|
||||
self.assertTrue(quick)
|
||||
|
||||
async def test_intermediate_quick_ack_does_not_change_body(self):
|
||||
body = b'a' * 40
|
||||
decoded, quick = await self._packet(struct.pack('<I', len(body) | 0x80000000) + body,
|
||||
PROTO_TAG_INTERMEDIATE)
|
||||
self.assertEqual(decoded, body)
|
||||
self.assertTrue(quick)
|
||||
|
||||
async def test_padded_plaintext_removes_all_fifteen_padding_lengths(self):
|
||||
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 20) + b'p' * 20
|
||||
for padding in range(16):
|
||||
wire = body + b'z' * padding
|
||||
decoded, quick = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
|
||||
self.assertEqual(decoded, body, padding)
|
||||
self.assertFalse(quick)
|
||||
|
||||
async def test_padded_ciphertext_removes_full_padding_blocks(self):
|
||||
body = b'k' * 8 + b'm' * 16 + b'c' * 32
|
||||
for padding in range(16):
|
||||
wire = body + b'z' * padding
|
||||
decoded, _ = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
|
||||
self.assertEqual(decoded, body, padding)
|
||||
|
||||
async def test_oversize_header_rejected_before_body_read(self):
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(struct.pack('<I', MAX_PACKET + 4))
|
||||
with self.assertRaisesRegex(ValueError, 'length'):
|
||||
await _read_packet(reader, _IdentityCipher(), PROTO_TAG_INTERMEDIATE)
|
||||
|
||||
def test_negative_transport_error_keeps_native_framing(self):
|
||||
error = struct.pack('<i', -404)
|
||||
self.assertEqual(_encode_reply(error, PROTO_TAG_ABRIDGED), b'\x01' + error)
|
||||
self.assertEqual(_encode_reply(error, PROTO_TAG_INTERMEDIATE), b'\x04\x00\x00\x00' + error)
|
||||
|
||||
|
||||
class HttpMultiplexTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.lane = _HttpLane('kws4.example.org', 1)
|
||||
await self.lane.client.aclose()
|
||||
self.channels = []
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.lane.close()
|
||||
|
||||
def _channel(self):
|
||||
channel = _HttpChannel(self.lane, len(self.channels) + 1, 'owned-test')
|
||||
self.channels.append(channel)
|
||||
return channel
|
||||
|
||||
def _transport(self, handler, *, trace_send=True):
|
||||
async def handle(request):
|
||||
if trace_send and 'trace' in request.extensions:
|
||||
# MockTransport receives the complete body before calling us.
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
|
||||
result = handler(request)
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
self.lane.client = httpx.AsyncClient(http2=True, transport=httpx.MockTransport(handle))
|
||||
|
||||
async def test_same_auth_prefix_out_of_order_replies_stay_with_source_channel(self):
|
||||
first_started, release_first = asyncio.Event(), asyncio.Event()
|
||||
body_a, body_b = b'samekey!' + b'a' * 32, b'samekey!' + b'b' * 32
|
||||
async def handler(request):
|
||||
if request.content == body_a:
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
return httpx.Response(200, content=request.content, extensions={
|
||||
'http_version': b'HTTP/2', 'stream_id': 3 if request.content == body_a else 5})
|
||||
self._transport(handler)
|
||||
first, second = self._channel(), self._channel()
|
||||
await first.send(body_a, False)
|
||||
await first_started.wait()
|
||||
await second.send(body_b, False)
|
||||
self.assertEqual(await asyncio.wait_for(second.receive(), 1), body_b)
|
||||
release_first.set()
|
||||
self.assertEqual(await asyncio.wait_for(first.receive(), 1), body_a)
|
||||
self.assertEqual(self.lane.max_inflight, 2)
|
||||
|
||||
async def test_close_one_channel_does_not_cancel_another(self):
|
||||
async def handler(request):
|
||||
if request.content[:1] == b'a':
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
first, second = self._channel(), self._channel()
|
||||
await first.send(b'a' * 40, False)
|
||||
await first.close()
|
||||
await second.send(b'b' * 40, False)
|
||||
self.assertEqual(await asyncio.wait_for(second.receive(), 1), b'b' * 40)
|
||||
await second.close()
|
||||
self.assertEqual(self.lane.inflight, 0)
|
||||
self.assertEqual(self.lane.queued_bytes, 0)
|
||||
self.assertFalse(self.lane.closed)
|
||||
|
||||
async def test_backpressure_waits_instead_of_dropping_requests(self):
|
||||
release = asyncio.Event()
|
||||
async def handler(request):
|
||||
await release.wait()
|
||||
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
for _ in range(MAX_CHANNEL_REQUESTS):
|
||||
await channel.send(b'x' * 40, False)
|
||||
extra = asyncio.create_task(channel.send(b'y' * 40, False))
|
||||
await asyncio.sleep(0.01)
|
||||
self.assertFalse(extra.done())
|
||||
self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS)
|
||||
release.set()
|
||||
await asyncio.wait_for(extra, 1)
|
||||
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(MAX_CHANNEL_REQUESTS + 1)]
|
||||
self.assertEqual(received.count(b'y' * 40), 1)
|
||||
|
||||
async def test_http_error_closes_only_its_channel_without_replaying_packet(self):
|
||||
calls = []
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
return httpx.Response(403, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(b'x' * 40, False)
|
||||
with self.assertRaisesRegex(ConnectionError, '403'):
|
||||
await asyncio.wait_for(channel.receive(), 1)
|
||||
self.assertEqual(calls, [b'x' * 40])
|
||||
self.assertTrue(channel.closed)
|
||||
self.assertFalse(self.lane.closed)
|
||||
|
||||
async def test_long_polls_cannot_block_fresh_request_and_retired_packet_recovers(self):
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(MAX_CHANNEL_REQUESTS)]
|
||||
fresh = b'samekey!' + b'n' * 32
|
||||
calls = []
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': len(calls) * 2 + 1})
|
||||
if request.content == fresh:
|
||||
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
if request.content == bodies[0] and calls.count(bodies[0]) == 2:
|
||||
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
await asyncio.Event().wait()
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
for body in bodies:
|
||||
await channel.send(body, False)
|
||||
await asyncio.sleep(0)
|
||||
original = next(iter(channel.pending))
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.wait_for(channel.send(fresh, False), 1)
|
||||
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(2)]
|
||||
self.assertCountEqual(received, [b'n' * 40, b'r' * 40])
|
||||
self.assertTrue(original.cancelled())
|
||||
self.assertTrue(channel.replay_history[0].retired)
|
||||
self.assertEqual(calls.count(bodies[0]), 2)
|
||||
self.assertEqual(channel.capacity_rotations, 1)
|
||||
self.assertEqual(self.lane.max_inflight, MAX_CHANNEL_REQUESTS)
|
||||
self.assertEqual(self.lane.errors, 0)
|
||||
self.assertFalse(channel.closed)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_shared_lane_long_polls_cannot_block_new_channel(self):
|
||||
fresh = b'samekey!' + b'n' * 32
|
||||
async def handler(request):
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
|
||||
if request.content == fresh:
|
||||
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
await asyncio.Event().wait()
|
||||
self._transport(handler)
|
||||
first, second, waiting = self._channel(), self._channel(), self._channel()
|
||||
with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 2), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
await first.send(b'samekey!' + b'a' * 32, False)
|
||||
await second.send(b'samekey!' + b'b' * 32, False)
|
||||
await asyncio.sleep(0)
|
||||
originals = [next(iter(channel.pending)) for channel in (first, second)]
|
||||
recovery = asyncio.create_task(waiting._recover())
|
||||
try:
|
||||
await asyncio.wait_for(waiting.send(fresh, False), 1)
|
||||
self.assertEqual(await asyncio.wait_for(waiting.receive(), 1), b'n' * 40)
|
||||
self.assertEqual(sum(task.cancelled() for task in originals), 1)
|
||||
self.assertEqual(sum(channel.capacity_rotations for channel in (first, second)), 1)
|
||||
self.assertEqual(self.lane.capacity_rotations, 1)
|
||||
self.assertEqual(self.lane.max_inflight, 2)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_upload_room_accounts_for_waiting_packet_size(self):
|
||||
for limit in ('MAX_CHANNEL_BYTES', 'MAX_LANE_BYTES'):
|
||||
with self.subTest(limit=limit):
|
||||
started = asyncio.Event()
|
||||
async def handler(request):
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
|
||||
if request.content == b'k' * 40:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
with patch('proxy.cf_h2.' + limit, 60), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
await channel.send(b'k' * 40, False)
|
||||
await started.wait()
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
# 20 bytes are free, but the pending upload needs 40.
|
||||
await asyncio.wait_for(channel.send(b'n' * 40, False), .5)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
|
||||
self.assertEqual(channel.capacity_rotations, 1)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
await channel.close()
|
||||
|
||||
async def test_close_releases_request_cancelled_before_coroutine_start_once(self):
|
||||
self._transport(lambda request: self.fail('cancelled request reached HTTP'))
|
||||
channel = self._channel()
|
||||
create_task = asyncio.create_task
|
||||
def cancelled_task(coroutine):
|
||||
task = create_task(coroutine)
|
||||
task.cancel()
|
||||
return task
|
||||
with patch('proxy.cf_h2.asyncio.create_task', cancelled_task):
|
||||
await channel.send(b'k' * 40, False)
|
||||
packet = channel.replay_history[0]
|
||||
await channel.close()
|
||||
await channel.close()
|
||||
self.assertEqual((self.lane.inflight, self.lane.queued_bytes, channel.pending_bytes), (0, 0, 0))
|
||||
self.assertFalse(channel.pending)
|
||||
self.assertFalse(channel.pending_since)
|
||||
self.assertFalse(channel.sent_since)
|
||||
self.assertFalse(packet.originals)
|
||||
|
||||
async def test_capacity_rotation_keeps_upload_and_started_response_intact(self):
|
||||
uploading, receiving, waiting = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
|
||||
fresh = b'samekey!' + b'n' * 32
|
||||
body_started = asyncio.Event()
|
||||
class SlowBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'r' * 20
|
||||
body_started.set()
|
||||
await asyncio.Event().wait()
|
||||
async def handler(request):
|
||||
if request.content == uploading:
|
||||
await asyncio.Event().wait() # No send_request_body.complete yet.
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
|
||||
if request.content == receiving:
|
||||
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
|
||||
if request.content == waiting:
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler, trace_send=False)
|
||||
channel = self._channel()
|
||||
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 3), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
for body in (uploading, receiving, waiting):
|
||||
await channel.send(body, False)
|
||||
await asyncio.wait_for(body_started.wait(), 1)
|
||||
tasks = list(channel.pending)
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.wait_for(channel.send(fresh, False), 1)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
|
||||
self.assertFalse(tasks[0].done())
|
||||
self.assertFalse(tasks[1].done())
|
||||
self.assertTrue(tasks[2].cancelled())
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_capacity_rotation_never_retires_unretained_or_plaintext_request(self):
|
||||
release = asyncio.Event()
|
||||
async def handler(request):
|
||||
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
|
||||
await release.wait()
|
||||
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 2), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002):
|
||||
await channel.send(b'\0' * 8 + b'p' * 32, False)
|
||||
await channel.send(b'samekey!' + b'u' * (64 * 1024), False)
|
||||
extra = asyncio.create_task(channel.send(b'samekey!' + b'n' * 32, False))
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.sleep(.04)
|
||||
self.assertFalse(extra.done())
|
||||
self.assertEqual(channel.capacity_rotations, 0)
|
||||
self.assertTrue(all(not task.done() for task in channel.pending))
|
||||
release.set()
|
||||
await asyncio.wait_for(extra, 1)
|
||||
finally:
|
||||
release.set()
|
||||
extra.cancel()
|
||||
recovery.cancel()
|
||||
await asyncio.gather(extra, recovery, return_exceptions=True)
|
||||
|
||||
async def test_http1_rejected_by_preflight(self):
|
||||
self._transport(lambda request: httpx.Response(200, extensions={'http_version': b'HTTP/1.1'}))
|
||||
with self.assertRaisesRegex(ValueError, 'HTTP/1.1'):
|
||||
await self.lane._preflight()
|
||||
|
||||
async def test_telegram_head_501_still_allows_http2_preflight(self):
|
||||
self._transport(lambda request: httpx.Response(501, extensions={'http_version': b'HTTP/2'}))
|
||||
await self.lane._preflight()
|
||||
|
||||
async def _packet_bridge(self, *, content_length=True, truncate=False, concurrent_small=False):
|
||||
first_chunk_read, release_tail = asyncio.Event(), asyncio.Event()
|
||||
body = b'x' * (64 * 1024 + 40)
|
||||
first_chunk = body[:16384]
|
||||
class ResponseStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield first_chunk
|
||||
first_chunk_read.set()
|
||||
await release_tail.wait()
|
||||
if not truncate:
|
||||
yield body[len(first_chunk):]
|
||||
async def handler(request):
|
||||
if request.content == b's' * 40:
|
||||
return httpx.Response(200, content=b'z' * 40,
|
||||
extensions={'http_version': b'HTTP/2'})
|
||||
headers = {'content-length': str(len(body))} if content_length else {}
|
||||
return httpx.Response(200, headers=headers, stream=ResponseStream(),
|
||||
extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
class Writer:
|
||||
def __init__(self):
|
||||
self.body = bytearray()
|
||||
self.changed = asyncio.Event()
|
||||
def write(self, data):
|
||||
self.body.extend(data)
|
||||
self.changed.set()
|
||||
async def drain(self):
|
||||
await asyncio.sleep(0)
|
||||
reader, writer = asyncio.StreamReader(), Writer()
|
||||
channel = self._channel()
|
||||
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
|
||||
task = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
|
||||
reader.feed_data(struct.pack('<I', 40) + b'l' * 40)
|
||||
try:
|
||||
await asyncio.wait_for(first_chunk_read.wait(), 1)
|
||||
self.assertEqual(writer.body, b'')
|
||||
if concurrent_small:
|
||||
reader.feed_data(struct.pack('<I', 40) + b's' * 40)
|
||||
await asyncio.wait_for(writer.changed.wait(), 1)
|
||||
self.assertEqual(writer.body, struct.pack('<I', 40) + b'z' * 40)
|
||||
release_tail.set()
|
||||
if truncate:
|
||||
await asyncio.wait_for(task, 1)
|
||||
self.assertTrue(channel.closed)
|
||||
self.assertEqual(writer.body, b'')
|
||||
else:
|
||||
expected = struct.pack('<I', len(body)) + body
|
||||
if concurrent_small:
|
||||
expected = struct.pack('<I', 40) + b'z' * 40 + expected
|
||||
for _ in range(100):
|
||||
if len(writer.body) >= len(expected):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(writer.body, expected)
|
||||
finally:
|
||||
release_tail.set()
|
||||
reader.feed_eof()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
async def test_native_frame_starts_only_after_entire_body_is_available(self):
|
||||
await self._packet_bridge()
|
||||
|
||||
async def test_ready_reply_bypasses_incomplete_large_response_on_same_channel(self):
|
||||
await self._packet_bridge(concurrent_small=True)
|
||||
|
||||
async def test_response_without_length_uses_buffered_framing(self):
|
||||
await self._packet_bridge(content_length=False)
|
||||
|
||||
async def test_truncated_stream_closes_native_channel(self):
|
||||
await self._packet_bridge(truncate=True)
|
||||
|
||||
async def test_recovery_pauses_during_body_receive_and_resumes_afterwards(self):
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
calls = []
|
||||
|
||||
class SlowBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'r' * 20
|
||||
started.set()
|
||||
await release.wait()
|
||||
yield b'r' * 20
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.content)
|
||||
if len(calls) == 1:
|
||||
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
|
||||
return httpx.Response(200, content=b's' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(b'x' * 40, False)
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.sleep(.06)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(channel.receiving_http, 1)
|
||||
release.set()
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b's' * 40)
|
||||
self.assertEqual(calls, [b'x' * 40, b'x' * 40])
|
||||
self.assertEqual(channel.receiving_http, 0)
|
||||
self.assertEqual(self.lane.reply_buffer_bytes, 0)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_lane_reply_memory_bound_and_cancellation_cleanup(self):
|
||||
started = asyncio.Event()
|
||||
|
||||
class SlowBody(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'r' * 32
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
def handler(request):
|
||||
if request.content == b'a' * 40:
|
||||
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
|
||||
return httpx.Response(200, content=b'r' * 16, extensions={'http_version': b'HTTP/2'})
|
||||
|
||||
self._transport(handler)
|
||||
slow = self._channel()
|
||||
with patch('proxy.cf_h2.MAX_LANE_REPLY_BYTES', 40):
|
||||
await slow.send(b'a' * 40, False)
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
self.assertEqual(self.lane.reply_buffer_bytes, 32)
|
||||
with self.assertRaisesRegex(BufferError, 'response buffers'):
|
||||
await self.lane._post(b'b' * 40, 2)
|
||||
self.assertEqual(self.lane.reply_buffer_bytes, 32)
|
||||
self.assertEqual(self.lane.failed_until, 0)
|
||||
await slow.close()
|
||||
self.assertEqual(slow.receiving_http, 0)
|
||||
self.assertEqual(self.lane.reply_buffer_bytes, 0)
|
||||
self.assertEqual(await self.lane._post(b'b' * 40, 2), b'r' * 16)
|
||||
|
||||
async def test_native_disconnect_does_not_cool_down_healthy_lane(self):
|
||||
self._transport(lambda request: httpx.Response(
|
||||
200, content=b'r' * 65536, extensions={'http_version': b'HTTP/2'}))
|
||||
|
||||
class DisconnectedWriter:
|
||||
def write(self, data):
|
||||
pass
|
||||
|
||||
async def drain(self):
|
||||
raise ConnectionResetError('Connection lost')
|
||||
|
||||
channel = self._channel()
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(struct.pack('<I', 40) + b'x' * 40)
|
||||
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
|
||||
await asyncio.wait_for(bridge_h2(reader, DisconnectedWriter(), channel, ctx,
|
||||
PROTO_TAG_INTERMEDIATE), 1)
|
||||
self.assertTrue(channel.closed)
|
||||
self.assertEqual(self.lane.failed_until, 0)
|
||||
self.assertEqual(self.lane.errors, 0)
|
||||
healthy = self._channel()
|
||||
await healthy.send(b'h' * 40, False)
|
||||
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'r' * 65536)
|
||||
|
||||
async def test_slow_receiver_queue_overflow_does_not_break_other_channels(self):
|
||||
async def handler(request):
|
||||
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
slow, healthy = self._channel(), self._channel()
|
||||
with patch('proxy.cf_h2.MAX_CHANNEL_BYTES', 80):
|
||||
for _ in range(3):
|
||||
await slow.send(b's' * 40, False)
|
||||
await asyncio.gather(*list(slow.pending))
|
||||
with self.assertRaises(BufferError):
|
||||
await asyncio.wait_for(slow.receive(), 1)
|
||||
await healthy.send(b'h' * 40, False)
|
||||
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'h' * 40)
|
||||
self.assertFalse(self.lane.closed)
|
||||
|
||||
async def test_simultaneous_pool_opens_preflight_once_and_share_client(self):
|
||||
pool = CfH2Pool()
|
||||
balancer = _Balancer()
|
||||
balancer.update_domains_list(['example.org'])
|
||||
started = []
|
||||
async def preflight(lane):
|
||||
started.append(lane)
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight):
|
||||
channels = await asyncio.gather(*(pool.open(4, 'owned-test') for _ in range(6)))
|
||||
self.assertEqual(len(started), 1)
|
||||
self.assertEqual(len({id(channel.lane.client) for channel in channels}), 1)
|
||||
self.assertEqual(len({channel.channel_id for channel in channels}), 6)
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
async def test_recovery_reuses_exact_ciphertext_when_original_http_is_still_pending(self):
|
||||
calls = []
|
||||
original_waiting = asyncio.Event()
|
||||
body = b'opaque-key-and-message-id' + b'x' * 16
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
if len(calls) == 1:
|
||||
original_waiting.set()
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(body, False)
|
||||
await original_waiting.wait()
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
self.assertEqual(calls, [body, body])
|
||||
self.assertEqual(channel.replay_requests, 1)
|
||||
self.assertEqual(self.lane.max_inflight, 2)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
await channel.close()
|
||||
self.assertFalse(channel.pending)
|
||||
self.assertEqual(self.lane.inflight, 0)
|
||||
|
||||
async def test_recovery_after_http_finished_without_native_reply(self):
|
||||
calls = []
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
return httpx.Response(200, content=b'' if len(calls) == 1 else b'r' * 40,
|
||||
extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
body = b'k' * 40
|
||||
await channel.send(body, False)
|
||||
await asyncio.gather(*list(channel.pending))
|
||||
self.assertTrue(channel.queue.empty())
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertEqual(calls, [body, body])
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_idle_receiver_preserves_capacity_for_new_native_request(self):
|
||||
calls = []
|
||||
probes_waiting = asyncio.Event()
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
if calls.count(request.content) > 1:
|
||||
if len(calls) == 4:
|
||||
probes_waiting.set()
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)]
|
||||
for body in bodies[:3]:
|
||||
await channel.send(body, False)
|
||||
await asyncio.gather(*list(channel.pending))
|
||||
await channel.receive()
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.wait_for(probes_waiting.wait(), 1)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertEqual(channel.replay_requests, 1)
|
||||
self.assertEqual(len(channel.replay_pending), 1)
|
||||
await asyncio.wait_for(channel.send(bodies[3], False), 1)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
self.assertEqual(calls[3:4], [bodies[2]])
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
await asyncio.gather(channel.close(), channel.close())
|
||||
self.assertEqual(self.lane.inflight, 0)
|
||||
self.assertEqual(channel.pending_bytes, 0)
|
||||
self.assertFalse(channel.replay_pending)
|
||||
|
||||
async def test_handshake_and_expired_ciphertext_are_never_replayed(self):
|
||||
calls = []
|
||||
async def handler(request):
|
||||
calls.append(request.content)
|
||||
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
for body in (b'\x00' * 8 + b'h' * 32, b'k' * 40):
|
||||
await channel.send(body, False)
|
||||
await asyncio.gather(*list(channel.pending))
|
||||
await channel.receive()
|
||||
# Windows may give consecutive monotonic() calls the same timestamp;
|
||||
# an event wakeup need not advance the clock like the old sleep did.
|
||||
channel.replay_history[-1].sent_at -= 1
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_MAX_AGE', 0):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.sleep(.04)
|
||||
self.assertEqual(len(calls), 2)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_unhelpful_replies_do_not_cause_unbounded_recovery(self):
|
||||
calls = []
|
||||
retries_finished = asyncio.Event()
|
||||
async def handler(request):
|
||||
calls.append((request.content, time.monotonic()))
|
||||
if len(calls) == 5:
|
||||
retries_finished.set()
|
||||
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)]
|
||||
for body in bodies:
|
||||
await channel.send(body, False)
|
||||
await asyncio.gather(*list(channel.pending))
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.wait_for(retries_finished.wait(), 1)
|
||||
await asyncio.sleep(.06)
|
||||
older = [when for body, when in calls if body == bodies[0]]
|
||||
latest = [when for body, when in calls if body == bodies[1]]
|
||||
# Completed history is not scanned; only the latest is polled.
|
||||
self.assertEqual(len(older), 1)
|
||||
self.assertEqual(len(latest), 4)
|
||||
for first, second in zip(latest[1:], latest[2:]):
|
||||
self.assertGreaterEqual(second - first, .015)
|
||||
self.assertFalse(channel.pending)
|
||||
self.assertFalse(channel.replay_pending)
|
||||
self.assertEqual(self.lane.inflight, 0)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_old_pending_request_has_bounded_retries_after_newer_requests(self):
|
||||
body = b'samekey!' + b't' * 32
|
||||
original_started = asyncio.Event()
|
||||
retries_finished = asyncio.Event()
|
||||
attempts = 0
|
||||
async def handler(request):
|
||||
nonlocal attempts
|
||||
if request.content == body:
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
original_started.set()
|
||||
await asyncio.Event().wait()
|
||||
if attempts == 4:
|
||||
retries_finished.set()
|
||||
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(body, False)
|
||||
await original_started.wait()
|
||||
original = next(iter(channel.pending))
|
||||
await channel.send(b'samekey!' + b'n' * 32, False)
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
await asyncio.wait_for(retries_finished.wait(), 1)
|
||||
await asyncio.sleep(.06)
|
||||
self.assertEqual(attempts, 4)
|
||||
self.assertFalse(original.done())
|
||||
self.assertFalse(channel.replay_pending)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_closed_pool_cannot_open_new_channels(self):
|
||||
pool = CfH2Pool()
|
||||
await pool.close()
|
||||
with self.assertRaisesRegex(ConnectionError, 'pool closed'):
|
||||
await pool.open(4, 'owned-test')
|
||||
|
||||
async def test_concurrent_upload_and_recovery_cannot_exceed_channel_request_bound(self):
|
||||
release = asyncio.Event()
|
||||
async def handler(request):
|
||||
await release.wait()
|
||||
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
for _ in range(MAX_CHANNEL_REQUESTS - 1):
|
||||
await channel.send(b'k' * 40, False)
|
||||
first = asyncio.create_task(channel.send(b'a' * 40, False))
|
||||
second = asyncio.create_task(channel.send(b'b' * 40, False))
|
||||
try:
|
||||
await asyncio.wait_for(first, 1)
|
||||
await asyncio.sleep(.02)
|
||||
self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS)
|
||||
self.assertFalse(second.done())
|
||||
release.set()
|
||||
await asyncio.wait_for(second, 1)
|
||||
finally:
|
||||
release.set()
|
||||
first.cancel()
|
||||
second.cancel()
|
||||
await asyncio.gather(first, second, return_exceptions=True)
|
||||
|
||||
async def test_failing_preflight_returns_to_ws_without_consuming_native_data(self):
|
||||
pool = CfH2Pool()
|
||||
balancer = _Balancer()
|
||||
balancer.update_domains_list(['example.org'])
|
||||
async def preflight(lane):
|
||||
raise OSError('owned connection failure')
|
||||
try:
|
||||
with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight):
|
||||
self.assertIsNone(await pool.open(4, 'owned-test'))
|
||||
self.assertFalse(pool.lanes)
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
async def test_hanging_setup_returns_to_ws_and_cleans_up_lane(self):
|
||||
pool = CfH2Pool()
|
||||
balancer = _Balancer()
|
||||
balancer.update_domains_list(['example.org'])
|
||||
lanes = []
|
||||
|
||||
async def preflight(lane):
|
||||
lanes.append(lane)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
try:
|
||||
with patch('proxy.cf_h2.balancer', balancer), \
|
||||
patch('proxy.cf_h2.SETUP_TIMEOUT', .02), \
|
||||
patch.object(_HttpLane, '_preflight', preflight):
|
||||
self.assertIsNone(await pool.open(4, 'owned-test'))
|
||||
self.assertIsNone(await pool.open(4, 'owned-test'))
|
||||
self.assertEqual(len(lanes), 1)
|
||||
self.assertTrue(lanes[0].closed)
|
||||
self.assertTrue(lanes[0].client.is_closed)
|
||||
self.assertFalse(pool.lanes)
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
async def test_recovery_does_not_wait_for_channel_or_lane_capacity(self):
|
||||
channel = self._channel()
|
||||
body = b'k' * 40
|
||||
channel.replay_history.append(_ReplayPacket(0, body, 0))
|
||||
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 0):
|
||||
self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1))
|
||||
with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 0):
|
||||
self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1))
|
||||
self.assertFalse(channel.send_lock.locked())
|
||||
self.assertFalse(channel.replay_history[0].replayed)
|
||||
self.assertEqual(channel.requests, 0)
|
||||
self.assertEqual(self.lane.inflight, 0)
|
||||
|
||||
async def test_recovery_does_not_queue_behind_native_upload(self):
|
||||
channel = self._channel()
|
||||
async with channel.send_lock:
|
||||
self.assertFalse(await asyncio.wait_for(channel.send(b'k' * 40, False, replay=True), .1))
|
||||
self.assertEqual(channel.requests, 0)
|
||||
|
||||
async def test_wait_diagnostics_combine_channels_and_are_rate_limited(self):
|
||||
async def handler(request):
|
||||
await asyncio.Event().wait()
|
||||
self._transport(handler)
|
||||
now = time.monotonic()
|
||||
for _ in range(2):
|
||||
channel = self._channel()
|
||||
await channel.send(b'k' * 40, False)
|
||||
channel.last_progress = now - 4
|
||||
channel.pending_since = dict.fromkeys(channel.pending, now - 4)
|
||||
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
for _ in range(20):
|
||||
self.lane.log_wait(now)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
self.assertIn('H2 WAIT', captured.output[0])
|
||||
self.assertIn('affected=2', captured.output[0])
|
||||
self.assertIn('channel=1', captured.output[0])
|
||||
self.assertIn('channel=2', captured.output[0])
|
||||
self.assertIn('recovery_left=', captured.output[0])
|
||||
|
||||
async def test_new_request_after_idle_does_not_log_a_stall(self):
|
||||
async def handler(request):
|
||||
await asyncio.Event().wait()
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(b'k' * 40, False)
|
||||
now = time.monotonic()
|
||||
channel.last_progress = now - 20
|
||||
with patch('proxy.cf_h2.log.isEnabledFor', return_value=True), \
|
||||
patch('proxy.cf_h2.log.debug') as debug:
|
||||
self.lane.log_wait(now)
|
||||
debug.assert_not_called()
|
||||
|
||||
async def test_wait_diagnostics_include_old_request_while_other_replies_progress(self):
|
||||
async def handler(request):
|
||||
await asyncio.Event().wait()
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
await channel.send(b'k' * 40, False)
|
||||
now = time.monotonic()
|
||||
channel.pending_since = dict.fromkeys(channel.pending, now - 4)
|
||||
channel.last_progress = now
|
||||
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
self.lane.log_wait(now)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
self.assertIn('waiting_headers=1', captured.output[0])
|
||||
self.assertIn('idle_ms=0', captured.output[0])
|
||||
|
||||
async def test_slow_response_diagnostics_are_rate_limited(self):
|
||||
self._transport(lambda request: httpx.Response(
|
||||
200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'}))
|
||||
with patch('proxy.cf_h2.SLOW_RESPONSE_SECONDS', 0), \
|
||||
self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
for _ in range(10):
|
||||
await self.lane._post(b'k' * 40, 1)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
self.assertIn('H2 SLOW', captured.output[0])
|
||||
self.assertIn('headers_ms=', captured.output[0])
|
||||
self.assertIn('body_ms=', captured.output[0])
|
||||
|
||||
async def test_pending_details_link_replay_to_original_and_bound_history(self):
|
||||
calls = 0
|
||||
original_waiting = asyncio.Event()
|
||||
async def handler(request):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
original_waiting.set()
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'})
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
await channel.send(b'samekey!' + b'p' * 32, False)
|
||||
await original_waiting.wait()
|
||||
original = next(iter(channel.pending))
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', 0):
|
||||
await channel.send(b'samekey!' + b'p' * 32, False, replay=True)
|
||||
await asyncio.gather(*(task for task in channel.pending if task is not original))
|
||||
trace = channel.request_traces[original]
|
||||
replay = channel.recent_traces[-1]
|
||||
self.assertEqual(replay.packet, trace.packet)
|
||||
self.assertNotEqual(replay.request, trace.request)
|
||||
self.assertTrue(replay.replay)
|
||||
now = time.monotonic()
|
||||
trace.started = now - 4
|
||||
channel.pending_since[original] = now - 4
|
||||
self.lane.log_wait(now)
|
||||
for index in range(24):
|
||||
await channel.send(b'samekey!' + bytes([index]) * 32, False)
|
||||
await asyncio.gather(*(task for task in channel.pending if task is not original))
|
||||
self.assertEqual(len(channel.recent_traces), 16)
|
||||
self.assertEqual(len(channel.request_traces), 1)
|
||||
output = '\n'.join(captured.output)
|
||||
self.assertIn('H2 DETAIL', output)
|
||||
self.assertIn('pkt=1', output)
|
||||
self.assertIn('replay=1 phase=complete', output)
|
||||
self.assertNotIn('samekey!', output)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""H2 belongs to CF fallback, never to the direct route."""
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from proxy import bridge, tg_ws_proxy
|
||||
from proxy.config import proxy_config
|
||||
from proxy.utils import PROTO_TAG_INTERMEDIATE
|
||||
|
||||
|
||||
class H2RoutingTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_direct_pool_hit_never_opens_h2(self):
|
||||
reader = Mock()
|
||||
writer = Mock()
|
||||
writer.get_extra_info.return_value = None
|
||||
writer.wait_closed = AsyncMock()
|
||||
ws = Mock(send=AsyncMock())
|
||||
pool = SimpleNamespace(open=AsyncMock())
|
||||
with patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.51'}), \
|
||||
patch.object(proxy_config, 'force_test_dc', False), \
|
||||
patch.object(tg_ws_proxy, 'cf_h2_pool', pool), \
|
||||
patch.object(tg_ws_proxy, 'set_sock_opts'), \
|
||||
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
|
||||
return_value=(b'', reader, writer, 'test'))), \
|
||||
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
|
||||
2, True, PROTO_TAG_INTERMEDIATE, b'')), \
|
||||
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
|
||||
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=ws)), \
|
||||
patch.object(tg_ws_proxy, 'bridge_ws_reencrypt', AsyncMock()) as direct, \
|
||||
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock()) as fallback:
|
||||
await tg_ws_proxy._handle_client(reader, writer, b'')
|
||||
direct.assert_awaited_once()
|
||||
fallback.assert_not_awaited()
|
||||
pool.open.assert_not_awaited()
|
||||
|
||||
async def test_pool_miss_goes_straight_to_fallback_including_test_dcs(self):
|
||||
for dc, forced, expected_test in [(2, False, False),
|
||||
(10002, False, True),
|
||||
(2, True, True)]:
|
||||
with self.subTest(dc=dc, forced=forced):
|
||||
reader = Mock()
|
||||
writer = Mock(wait_closed=AsyncMock())
|
||||
writer.get_extra_info.return_value = None
|
||||
with patch.object(proxy_config, 'force_test_dc', forced), \
|
||||
patch.object(tg_ws_proxy, 'set_sock_opts'), \
|
||||
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
|
||||
return_value=(b'', reader, writer, 'test'))), \
|
||||
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
|
||||
dc, False, PROTO_TAG_INTERMEDIATE, b'')), \
|
||||
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
|
||||
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=None)) as get, \
|
||||
patch('proxy.raw_websocket.RawWebSocket.connect', AsyncMock()) as connect, \
|
||||
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock(return_value=True)) as fallback:
|
||||
await tg_ws_proxy._handle_client(reader, writer, b'')
|
||||
get.assert_awaited_once_with(2, False, is_test_dc=expected_test)
|
||||
fallback.assert_awaited_once()
|
||||
self.assertEqual(fallback.call_args.args[4:7], (2, expected_test, False))
|
||||
connect.assert_not_awaited()
|
||||
|
||||
async def test_cf_fallback_order_and_route_guards(self):
|
||||
cases = [
|
||||
# media, test DC, CF enabled, Worker succeeds, H2 opens, expected
|
||||
(True, False, True, False, True, ['h2', 'bridge']),
|
||||
(True, False, True, False, False, ['h2', 'ws']),
|
||||
(False, False, True, False, True, ['ws']),
|
||||
(True, True, True, False, True, ['tcp']),
|
||||
(True, False, False, False, True, ['tcp']),
|
||||
(True, False, True, True, True, ['worker']),
|
||||
]
|
||||
for media, test_dc, cf, worker, opens, expected in cases:
|
||||
with self.subTest(expected=expected, media=media, test_dc=test_dc, cf=cf):
|
||||
calls = []
|
||||
|
||||
async def open_h2(*args):
|
||||
calls.append('h2')
|
||||
return object() if opens else None
|
||||
|
||||
def route(name):
|
||||
async def run(*args, **kwargs):
|
||||
calls.append(name)
|
||||
return True
|
||||
return run
|
||||
|
||||
pool = SimpleNamespace(open=AsyncMock(side_effect=open_h2))
|
||||
with patch.object(proxy_config, 'fallback_cfproxy', cf), \
|
||||
patch.object(proxy_config, 'cfproxy_worker_domains',
|
||||
['worker.example'] if worker else []), \
|
||||
patch.object(bridge, 'bridge_h2', side_effect=route('bridge')), \
|
||||
patch.object(bridge, '_cfproxy_fallback', side_effect=route('ws')), \
|
||||
patch.object(bridge, '_cfproxy_worker_fallback', side_effect=route('worker')), \
|
||||
patch.object(bridge, '_tcp_fallback', side_effect=route('tcp')):
|
||||
result = await bridge.do_fallback(
|
||||
Mock(), Mock(), b'', 'test', 2, test_dc, media, '', Mock(),
|
||||
h2_pool=pool, proto_tag=PROTO_TAG_INTERMEDIATE)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(calls, expected)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Default/CLI/GUI configuration must select the same effective H2 route."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from proxy.config import ProxyConfig
|
||||
from proxy import tg_ws_proxy
|
||||
from utils import tray_common
|
||||
from utils.default_config import default_tray_config
|
||||
from ui.settings import prepare_settings
|
||||
|
||||
|
||||
class H2SettingsTest(unittest.TestCase):
|
||||
def test_cli_default_opt_out_and_existing_launch_script(self):
|
||||
cases = [([], True), (['--no-h2'], False),
|
||||
(['--cfproxy-h2-media'], True), (['--no-cfproxy'], False),
|
||||
(['--no-secure'], False), (['--force-test-dc'], False)]
|
||||
for flags, enabled in cases:
|
||||
with self.subTest(flags=flags):
|
||||
config = ProxyConfig()
|
||||
observed = []
|
||||
|
||||
async def run():
|
||||
observed.append(config.h2_enabled)
|
||||
|
||||
root = logging.getLogger()
|
||||
previous_handlers, previous_level = root.handlers[:], root.level
|
||||
try:
|
||||
with patch.object(tg_ws_proxy, 'proxy_config', config), \
|
||||
patch.object(tg_ws_proxy, '_run', run), \
|
||||
patch('sys.argv', ['proxy', '--secret', 'ab' * 16] + flags):
|
||||
tg_ws_proxy.main()
|
||||
self.assertEqual(observed, [enabled])
|
||||
finally:
|
||||
for handler in root.handlers[:]:
|
||||
if handler not in previous_handlers:
|
||||
root.removeHandler(handler)
|
||||
handler.close()
|
||||
root.setLevel(previous_level)
|
||||
|
||||
def test_help_advertises_opt_out_only(self):
|
||||
output = io.StringIO()
|
||||
with patch('sys.argv', ['proxy', '--help']), contextlib.redirect_stdout(output):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
tg_ws_proxy.main()
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
self.assertIn('--no-h2', output.getvalue())
|
||||
self.assertNotIn('--cfproxy-h2-media', output.getvalue())
|
||||
|
||||
def test_saved_setting_migration_and_runtime_restart(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'config.json'
|
||||
path.write_text(json.dumps({'port': 1444}), encoding='utf-8')
|
||||
with patch.object(tray_common, 'CONFIG_FILE', path), \
|
||||
patch.object(tray_common, 'ensure_dirs'), \
|
||||
patch.object(tray_common, '_apply_ui_language'):
|
||||
config = tray_common.load_config()
|
||||
self.assertTrue(config['h2'])
|
||||
change = prepare_settings(config, {'h2': False}, default_tray_config())
|
||||
self.assertTrue(change.requires_restart)
|
||||
tray_common.save_config(change.config)
|
||||
saved = tray_common.load_config()
|
||||
self.assertFalse(saved['h2'])
|
||||
runtime = ProxyConfig()
|
||||
with patch.object(tray_common, 'proxy_config', runtime):
|
||||
self.assertTrue(tray_common.apply_proxy_config(saved))
|
||||
self.assertFalse(runtime.h2_enabled)
|
||||
saved['h2'] = True
|
||||
self.assertTrue(tray_common.apply_proxy_config(saved))
|
||||
self.assertTrue(runtime.h2_enabled)
|
||||
for key, value in [('cfproxy', False), ('no_secure', True),
|
||||
('force_test_dc', True)]:
|
||||
self.assertTrue(tray_common.apply_proxy_config({**saved, key: value}))
|
||||
self.assertFalse(runtime.h2_enabled)
|
||||
self.assertTrue(runtime.cfproxy_h2_media)
|
||||
|
||||
|
||||
class H2ServerLifecycleTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_stop_and_cancellation_close_default_h2_and_listener_tasks(self):
|
||||
for cancel in (False, True):
|
||||
with self.subTest(cancel=cancel):
|
||||
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0)
|
||||
stop = asyncio.Event()
|
||||
before = asyncio.all_tasks()
|
||||
with patch.object(tg_ws_proxy, 'proxy_config', config), \
|
||||
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
|
||||
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
|
||||
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()):
|
||||
server = asyncio.create_task(tg_ws_proxy._run(stop))
|
||||
writer = None
|
||||
try:
|
||||
async def listening():
|
||||
while tg_ws_proxy._server_instance is None:
|
||||
await asyncio.sleep(.005)
|
||||
await asyncio.wait_for(listening(), 1)
|
||||
pool = tg_ws_proxy.cf_h2_pool
|
||||
self.assertIsNotNone(pool)
|
||||
port = tg_ws_proxy._server_instance.sockets[0].getsockname()[1]
|
||||
_, writer = await asyncio.open_connection('127.0.0.1', port)
|
||||
if cancel:
|
||||
server.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(server, 2)
|
||||
else:
|
||||
stop.set()
|
||||
await asyncio.wait_for(server, 2)
|
||||
self.assertTrue(pool.closed)
|
||||
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
|
||||
self.assertIsNone(tg_ws_proxy._server_instance)
|
||||
self.assertFalse(tg_ws_proxy._client_tasks)
|
||||
self.assertFalse(asyncio.all_tasks() - before)
|
||||
finally:
|
||||
server.cancel()
|
||||
await asyncio.gather(server, return_exceptions=True)
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,858 @@
|
||||
"""Wire-level regressions: real TLS sockets, ALPN and HTTP/2 flow control."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import ipaddress
|
||||
import ssl
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from h2.config import H2Configuration
|
||||
from h2.connection import H2Connection
|
||||
from h2.events import DataReceived, RequestReceived, StreamEnded, StreamReset
|
||||
from h2.settings import SettingCodes
|
||||
|
||||
from proxy.h2_transport import H2Transport, STREAM_RECEIVE_WINDOW, _Connection
|
||||
from proxy.cf_h2 import _HttpChannel, _HttpLane, bridge_h2
|
||||
from proxy.utils import PROTO_TAG_INTERMEDIATE
|
||||
|
||||
|
||||
class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
cls.folder = tempfile.TemporaryDirectory(prefix='tg-h2-tests-')
|
||||
cls.addClassCleanup(cls.folder.cleanup)
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(minutes=1))
|
||||
.not_valid_after(now + datetime.timedelta(hours=1))
|
||||
.add_extension(x509.SubjectAlternativeName([
|
||||
x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False)
|
||||
.sign(key, hashes.SHA256()))
|
||||
cls.cert_path = Path(cls.folder.name) / 'cert.pem'
|
||||
key_path = Path(cls.folder.name) / 'key.pem'
|
||||
cls.cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
cls.server_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
cls.server_ssl.load_cert_chain(str(cls.cert_path), str(key_path))
|
||||
cls.server_ssl.set_alpn_protocols(['h2'])
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.requests = asyncio.Queue()
|
||||
self.resets = asyncio.Queue()
|
||||
self.connections = []
|
||||
self.handlers = []
|
||||
self.tasks = []
|
||||
self.server_max_streams = 64
|
||||
self.server = await asyncio.start_server(self.serve, '127.0.0.1', 0, ssl=self.server_ssl)
|
||||
port = self.server.sockets[0].getsockname()[1]
|
||||
self.lane = _HttpLane('127.0.0.1:%d' % port, 1)
|
||||
await self.lane.client.aclose()
|
||||
self.transport = H2Transport(ssl.create_default_context(cafile=str(self.cert_path)))
|
||||
self.lane.client = httpx.AsyncClient(transport=self.transport, trust_env=False,
|
||||
timeout=httpx.Timeout(2, read=1))
|
||||
|
||||
async def asyncTearDown(self):
|
||||
for task in self.tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*self.tasks, return_exceptions=True)
|
||||
await self.lane.close()
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
for connection in self.connections:
|
||||
connection.writer.close()
|
||||
await asyncio.wait_for(asyncio.gather(*self.handlers), 3)
|
||||
|
||||
async def serve(self, reader, writer):
|
||||
self.handlers.append(asyncio.current_task())
|
||||
conn = SimpleNamespace(h2=H2Connection(config=H2Configuration(client_side=False)),
|
||||
writer=writer, bodies={}, pending={})
|
||||
self.connections.append(conn)
|
||||
conn.h2.initiate_connection()
|
||||
conn.h2.update_settings({SettingCodes.MAX_CONCURRENT_STREAMS: self.server_max_streams})
|
||||
writer.write(conn.h2.data_to_send())
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
break
|
||||
for event in conn.h2.receive_data(data):
|
||||
if isinstance(event, RequestReceived):
|
||||
conn.bodies[event.stream_id] = bytearray()
|
||||
elif isinstance(event, DataReceived):
|
||||
conn.bodies[event.stream_id].extend(event.data)
|
||||
conn.h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
|
||||
elif isinstance(event, StreamEnded):
|
||||
await self.requests.put((conn, event.stream_id, bytes(conn.bodies[event.stream_id])))
|
||||
elif isinstance(event, StreamReset):
|
||||
conn.pending.pop(event.stream_id, None)
|
||||
await self.resets.put((event.stream_id, event.error_code))
|
||||
self.flush(conn)
|
||||
await writer.drain()
|
||||
except (ConnectionError, ssl.SSLError):
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except (ConnectionError, ssl.SSLError):
|
||||
pass
|
||||
|
||||
def flush(self, conn):
|
||||
for sid, (body, end) in list(conn.pending.items()):
|
||||
while body:
|
||||
count = min(len(body), conn.h2.max_outbound_frame_size,
|
||||
conn.h2.local_flow_control_window(sid))
|
||||
if count <= 0:
|
||||
break
|
||||
chunk, body = body[:count], body[count:]
|
||||
conn.h2.send_data(sid, chunk, end_stream=end and not body)
|
||||
if body:
|
||||
conn.pending[sid] = (body, end)
|
||||
else:
|
||||
del conn.pending[sid]
|
||||
conn.writer.write(conn.h2.data_to_send())
|
||||
|
||||
def respond(self, request, body=b'r' * 40, *, end=True, length=None, status=200):
|
||||
conn, sid, _ = request
|
||||
conn.h2.send_headers(sid, [(b':status', str(status).encode()),
|
||||
(b'content-type', b'application/octet-stream'),
|
||||
(b'content-length', str(len(body) if length is None else length).encode())],
|
||||
end_stream=end and not body)
|
||||
if body:
|
||||
conn.pending[sid] = (body, end)
|
||||
self.flush(conn)
|
||||
|
||||
async def post(self, body=b'x' * 40):
|
||||
task = asyncio.create_task(self.lane._post(body, len(self.tasks) + 1))
|
||||
self.tasks.append(task)
|
||||
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||
self.assertEqual(request[2], body)
|
||||
return task, request
|
||||
|
||||
async def test_fast_response_does_not_wait_for_unrelated_long_polls(self):
|
||||
slow1, request1 = await self.post()
|
||||
slow2, request2 = await self.post()
|
||||
fast, request3 = await self.post()
|
||||
self.respond(request3)
|
||||
self.assertEqual(await asyncio.wait_for(fast, .5), b'r' * 40)
|
||||
self.assertFalse(slow1.done())
|
||||
self.assertFalse(slow2.done())
|
||||
self.respond(request1)
|
||||
self.respond(request2)
|
||||
await asyncio.gather(slow1, slow2)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_debug_trace_follows_real_stream_without_logging_payload(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'trace-test')
|
||||
body = b'private-payload-' + b'x' * 24
|
||||
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||
await channel.send(body, False)
|
||||
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||
trace = next(iter(channel.request_traces.values()))
|
||||
|
||||
async def phase(expected):
|
||||
while trace.phase != expected:
|
||||
await asyncio.sleep(.001)
|
||||
|
||||
await asyncio.wait_for(phase('headers'), .5)
|
||||
self.assertEqual(trace.stream, request[1])
|
||||
self.assertEqual(trace.packet, 1)
|
||||
self.assertEqual(trace.request, 1)
|
||||
self.assertIsNotNone(trace.sent_at)
|
||||
self.assertIsNone(trace.headers_at)
|
||||
self.respond(request, b'r' * 20, end=False, length=40)
|
||||
await asyncio.wait_for(phase('body'), .5)
|
||||
self.assertIsNotNone(trace.headers_at)
|
||||
conn, sid, _ = request
|
||||
conn.h2.send_data(sid, b'r' * 20, end_stream=True)
|
||||
self.flush(conn)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||
await asyncio.gather(*list(channel.pending))
|
||||
self.assertEqual(trace.received, 40)
|
||||
self.assertEqual(trace.phase, 'complete')
|
||||
self.assertFalse(channel.request_traces)
|
||||
self.assertIs(channel.recent_traces[-1], trace)
|
||||
self.lane.log_flow(trace.finished_at)
|
||||
await channel.close()
|
||||
output = '\n'.join(captured.output)
|
||||
self.assertIn('http_down=40', output)
|
||||
self.assertIn('small=1 large=0', output)
|
||||
self.assertNotIn('private-payload', output)
|
||||
self.assertFalse(channel.recent_traces)
|
||||
|
||||
async def test_stale_recovery_slot_replaced_without_resetting_original_request(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'recovery-wire-test')
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
|
||||
originals = []
|
||||
for body in bodies[:2]:
|
||||
await channel.send(body, False)
|
||||
originals.append(await asyncio.wait_for(self.requests.get(), 1))
|
||||
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .1):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
old_poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||
other_poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.assertEqual([old_poll[2], other_poll[2]], bodies[:2])
|
||||
|
||||
# The two duplicate requests remain silent. A new native request
|
||||
# also gets no reply until its own duplicate solicits one.
|
||||
await channel.send(bodies[2], False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
probe = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.assertEqual(probe[2], bodies[2])
|
||||
reset_sid, reset_code = await asyncio.wait_for(self.resets.get(), 1)
|
||||
self.assertEqual((reset_sid, reset_code), (old_poll[1], 8))
|
||||
self.assertIn(original[1], self.transport.connection.streams)
|
||||
for request in originals:
|
||||
self.assertIn(request[1], self.transport.connection.streams)
|
||||
self.assertIn(other_poll[1], self.transport.connection.streams)
|
||||
self.assertEqual(len(channel.replay_pending), 2)
|
||||
self.respond(probe, b'r' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
# The original stream remains usable, even after recovery.
|
||||
self.respond(original, b'o' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'o' * 40)
|
||||
self.assertEqual(channel.replay_rotations, 1)
|
||||
self.assertEqual(self.lane.failed_until, 0)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_normal_long_poll_survives_six_seconds_and_delivers_late_reply(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'normal-long-poll-test')
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
|
||||
for body in bodies[:2]:
|
||||
await channel.send(body, False)
|
||||
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||
await asyncio.wait_for(channel.receive(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
poll = await asyncio.wait_for(self.requests.get(), 1)
|
||||
# Advance request ages, keeping the real TLS/H2 streams open.
|
||||
# Six seconds is still inside Telegram's default 25s HTTP wait.
|
||||
for task in channel.replay_pending:
|
||||
channel.pending_since[task] = time.monotonic() - 6
|
||||
channel.sent_since[task] = time.monotonic() - 6
|
||||
await asyncio.sleep(.03)
|
||||
self.assertTrue(self.resets.empty(), 'normal HTTP wait was reset before its delayed result')
|
||||
await channel.send(bodies[2], False)
|
||||
fresh = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.assertEqual(fresh[2], bodies[2])
|
||||
self.respond(fresh, b'n' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
|
||||
self.respond(poll, b'd' * (128 * 1024))
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'd' * (128 * 1024))
|
||||
self.assertEqual(channel.replay_rotations, 0)
|
||||
self.assertEqual(channel.replay_requests, 1)
|
||||
|
||||
async def test_replay_rotation_does_not_interrupt_upload(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'replay-upload-test')
|
||||
body = b'samekey!' + b'p' * 32
|
||||
await channel.send(body, False)
|
||||
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||
await asyncio.wait_for(channel.receive(), 1)
|
||||
upload_started, resume = asyncio.Event(), asyncio.Event()
|
||||
send_body = _Connection.send_body
|
||||
|
||||
async def paused_send(connection, request, stream):
|
||||
upload_started.set()
|
||||
await resume.wait()
|
||||
await send_body(connection, request, stream)
|
||||
|
||||
with patch.object(_Connection, 'send_body', paused_send), \
|
||||
patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
await asyncio.wait_for(upload_started.wait(), 1)
|
||||
probe = next(iter(channel.replay_pending))
|
||||
channel.pending_since[probe] = time.monotonic() - 40
|
||||
await asyncio.sleep(.03)
|
||||
self.assertFalse(probe.done())
|
||||
self.assertTrue(self.resets.empty())
|
||||
self.assertEqual(channel.replay_rotations, 0)
|
||||
resume.set()
|
||||
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(request)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
|
||||
async def test_recovery_deadline_starts_after_original_upload_finishes(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'unfinished-upload-test')
|
||||
body = b'samekey!' + b'q' * 32
|
||||
started, resume = asyncio.Event(), asyncio.Event()
|
||||
send_body = _Connection.send_body
|
||||
|
||||
async def paused_send(connection, request, stream):
|
||||
started.set()
|
||||
await resume.wait()
|
||||
await send_body(connection, request, stream)
|
||||
|
||||
with patch.object(_Connection, 'send_body', paused_send), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
await channel.send(body, False)
|
||||
await asyncio.wait_for(started.wait(), .5)
|
||||
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||
await asyncio.sleep(.06)
|
||||
self.assertEqual(channel.requests, 1, 'unsent original was duplicated')
|
||||
self.assertTrue(self.requests.empty())
|
||||
resume.set()
|
||||
original = await asyncio.wait_for(self.requests.get(), .5)
|
||||
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(original[2], body)
|
||||
self.assertEqual(probe[2], body)
|
||||
self.respond(probe)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||
self.assertTrue(self.resets.empty())
|
||||
|
||||
async def test_body_tail_does_not_wait_for_unrelated_long_polls(self):
|
||||
slow1, _ = await self.post()
|
||||
slow2, _ = await self.post()
|
||||
fast, request = await self.post()
|
||||
self.respond(request, b'a' * 20, end=False, length=40)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertFalse(fast.done())
|
||||
conn, sid, _ = request
|
||||
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
|
||||
self.flush(conn)
|
||||
self.assertEqual(await asyncio.wait_for(fast, .5), b'a' * 20 + b'b' * 20)
|
||||
self.assertFalse(slow1.done())
|
||||
self.assertFalse(slow2.done())
|
||||
|
||||
async def test_receiving_body_is_marked_before_request_coroutine_resumes(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'early-headers-test')
|
||||
resume = asyncio.Event()
|
||||
send_body = _Connection.send_body
|
||||
|
||||
async def paused_send(connection, request, stream):
|
||||
await send_body(connection, request, stream)
|
||||
await resume.wait()
|
||||
|
||||
with patch.object(_Connection, 'send_body', paused_send):
|
||||
await channel.send(b'k' * 40, False)
|
||||
request = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(request, b'a' * 20, end=False, length=40)
|
||||
|
||||
async def headers_received():
|
||||
while channel.receiving_http == 0:
|
||||
await asyncio.sleep(.005)
|
||||
try:
|
||||
await asyncio.wait_for(headers_received(), .5)
|
||||
self.assertEqual(channel.receiving_http, 1)
|
||||
self.assertTrue(channel.queue.empty())
|
||||
finally:
|
||||
resume.set()
|
||||
conn, sid, _ = request
|
||||
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
|
||||
self.flush(conn)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 20 + b'b' * 20)
|
||||
self.assertEqual(channel.receiving_http, 0)
|
||||
|
||||
async def test_native_channel_delivers_ready_reply_while_large_body_is_pending(self):
|
||||
class IdentityCipher:
|
||||
def update(self, data):
|
||||
return data
|
||||
|
||||
class Writer:
|
||||
def __init__(self):
|
||||
self.data = bytearray()
|
||||
self.changed = asyncio.Event()
|
||||
|
||||
def write(self, data):
|
||||
self.data.extend(data)
|
||||
self.changed.set()
|
||||
|
||||
async def drain(self):
|
||||
pass
|
||||
|
||||
reader, writer = asyncio.StreamReader(), Writer()
|
||||
channel = _HttpChannel(self.lane, 1, 'wire-test')
|
||||
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
|
||||
bridge = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
|
||||
self.tasks.append(bridge)
|
||||
reader.feed_data(struct.pack('<I', 40) + b'x' * 40)
|
||||
slow = await asyncio.wait_for(self.requests.get(), 2)
|
||||
self.respond(slow, b'a' * 65536, end=False, length=65576)
|
||||
reader.feed_data(struct.pack('<I', 40) + b'y' * 40)
|
||||
fast = await asyncio.wait_for(self.requests.get(), 2)
|
||||
self.respond(fast, b'f' * 40)
|
||||
await asyncio.wait_for(writer.changed.wait(), .5)
|
||||
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40)
|
||||
writer.changed.clear()
|
||||
conn, sid, _ = slow
|
||||
# Finish after the first 64 KiB have passed through flow control.
|
||||
for _ in range(100):
|
||||
if sid not in conn.pending:
|
||||
break
|
||||
await asyncio.sleep(.001)
|
||||
self.assertNotIn(sid, conn.pending)
|
||||
conn.pending[sid] = (b'b' * 40, True)
|
||||
self.flush(conn)
|
||||
await asyncio.wait_for(writer.changed.wait(), .5)
|
||||
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40
|
||||
+ struct.pack('<I', 65576) + b'a' * 65536 + b'b' * 40)
|
||||
|
||||
async def test_parallel_first_windows_do_not_wait_for_connection_credit(self):
|
||||
first, request1 = await self.post()
|
||||
second, request2 = await self.post()
|
||||
self.respond(request1, b'a' * 65532)
|
||||
self.respond(request2, b'b' * 65532)
|
||||
# No event-loop yield between responses: the server has not received
|
||||
# any WINDOW_UPDATE credit for either body yet.
|
||||
self.assertFalse(request1[0].pending)
|
||||
self.assertEqual(await first, b'a' * 65532)
|
||||
self.assertEqual(await second, b'b' * 65532)
|
||||
|
||||
async def test_file_chunk_fits_first_window_without_extra_network_round_trip(self):
|
||||
# A 128 KiB file part plus its MTProto envelope must fit before any
|
||||
# WINDOW_UPDATE arrives. This matters even when the consumer is fast.
|
||||
body = b'f' * (128 * 1024 + 168)
|
||||
task, request = await self.post()
|
||||
self.respond(request, body)
|
||||
self.assertNotIn(request[1], list(request[0].pending))
|
||||
self.assertEqual(await asyncio.wait_for(task, 1), body)
|
||||
|
||||
async def test_stalled_body_does_not_block_recovery_of_another_native_request(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'stalled-body-test')
|
||||
await channel.send(b'k' * 8 + b'a' * 32, False)
|
||||
slow = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(slow, b'a' * 20, end=False, length=40)
|
||||
fresh_body = b'k' * 8 + b'b' * 32
|
||||
await channel.send(fresh_body, False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .05):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(probe[2], fresh_body)
|
||||
self.respond(probe, b'r' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||
self.assertIn(slow[1], self.transport.connection.streams)
|
||||
self.assertIn(original[1], self.transport.connection.streams)
|
||||
self.assertTrue(self.resets.empty())
|
||||
conn, sid, _ = slow
|
||||
conn.h2.send_data(sid, b'a' * 20, end_stream=True)
|
||||
self.flush(conn)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'a' * 40)
|
||||
self.assertEqual(self.lane.failed_until, 0)
|
||||
|
||||
async def test_first_recovery_without_result_does_not_end_recovery(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'second-recovery-test')
|
||||
body = b'k' * 40
|
||||
await channel.send(body, False)
|
||||
self.respond(await asyncio.wait_for(self.requests.get(), 1), b'')
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
first = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.respond(first, b'')
|
||||
second = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(first[2], body)
|
||||
self.assertEqual(second[2], body)
|
||||
self.respond(second, b'r' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||
self.assertEqual(channel.replay_requests, 2)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_completed_http_ack_does_not_add_idle_delay_to_each_chunk(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'chunked-download-poll-test')
|
||||
# An HTTP reply can be only an MTProto acknowledgement. The queued
|
||||
# file chunk needs another HTTP receiver, with no new native upload.
|
||||
# Keep the production 1s idle threshold; shorten only scheduler ticks.
|
||||
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||
for index in range(4):
|
||||
body = b'samekey!' + bytes([index]) * 32
|
||||
await channel.send(body, False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.assertEqual(original[2], body)
|
||||
self.respond(original, b'a' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
|
||||
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(poll[2], body)
|
||||
reply = bytes([index]) * (128 * 1024)
|
||||
self.respond(poll, reply)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply)
|
||||
self.assertTrue(self.resets.empty())
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_drained_receiver_wakes_first_poll_without_waiting_for_timer(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'receiver-wakeup-test')
|
||||
body = b'samekey!' + b'q' * 32
|
||||
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
|
||||
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||
await channel.send(body, False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(original, b'a' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
|
||||
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(poll[2], body)
|
||||
self.respond(poll, b'f' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'f' * 40)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertTrue(self.requests.empty(), 'a wakeup bypassed the retry backoff')
|
||||
self.assertEqual(channel.replay_requests, 1)
|
||||
|
||||
async def test_native_write_completion_wakes_poll_but_backpressure_does_not(self):
|
||||
class IdentityCipher:
|
||||
def update(self, data):
|
||||
return data
|
||||
|
||||
class Writer:
|
||||
def __init__(self):
|
||||
self.started, self.resume = asyncio.Event(), asyncio.Event()
|
||||
|
||||
def write(self, data):
|
||||
self.started.set()
|
||||
|
||||
async def drain(self):
|
||||
await self.resume.wait()
|
||||
|
||||
channel = _HttpChannel(self.lane, 1, 'native-wakeup-test')
|
||||
reader, writer = asyncio.StreamReader(), Writer()
|
||||
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
|
||||
body = b'samekey!' + b'q' * 32
|
||||
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
|
||||
self.tasks.append(asyncio.create_task(
|
||||
bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE)))
|
||||
reader.feed_data(struct.pack('<I', len(body)) + body)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(original)
|
||||
await asyncio.wait_for(writer.started.wait(), .5)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertTrue(self.requests.empty())
|
||||
writer.resume.set()
|
||||
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(poll[2], body)
|
||||
self.assertIsNone(channel.delivering_since)
|
||||
self.assertEqual(channel.down, 40)
|
||||
|
||||
async def test_idle_poll_preserves_slot_for_new_overdue_request(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'idle-poll-capacity-test')
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)]
|
||||
for body in bodies[:3]:
|
||||
await channel.send(body, False)
|
||||
self.respond(await asyncio.wait_for(self.requests.get(), 1))
|
||||
await asyncio.wait_for(channel.receive(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .06), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
|
||||
self.tasks.append(asyncio.create_task(channel._recover()))
|
||||
poll = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(poll[2], bodies[2])
|
||||
await asyncio.sleep(.04)
|
||||
self.assertTrue(self.requests.empty(), 'completed history occupied the spare recovery slot')
|
||||
await channel.send(bodies[3], False)
|
||||
original = await asyncio.wait_for(self.requests.get(), .5)
|
||||
recovery = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(original[2], bodies[3])
|
||||
self.assertEqual(recovery[2], bodies[3])
|
||||
self.respond(recovery, b'n' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
|
||||
# Neither the old receiver nor the new original was cancelled.
|
||||
self.assertTrue(self.resets.empty())
|
||||
self.respond(poll, b'p' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'p' * 40)
|
||||
self.respond(original, b'o' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'o' * 40)
|
||||
self.assertEqual(channel.replay_rotations, 0)
|
||||
|
||||
async def test_busy_channel_recovers_old_request_despite_newer_replies(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'busy-recovery-test')
|
||||
body = b'samekey!' + b't' * 32
|
||||
original = None
|
||||
recovered = asyncio.Event()
|
||||
chatter_reply = asyncio.Event()
|
||||
chatter_requests = 0
|
||||
|
||||
async def server():
|
||||
nonlocal original, chatter_requests
|
||||
while True:
|
||||
request = await self.requests.get()
|
||||
if request[2] == body:
|
||||
if original is None:
|
||||
original = request
|
||||
else:
|
||||
self.respond(request, b't' * 40)
|
||||
else:
|
||||
chatter_requests += 1
|
||||
self.respond(request, b'c' * 40)
|
||||
|
||||
async def chatter():
|
||||
index = 0
|
||||
while True:
|
||||
chatter_reply.clear()
|
||||
await channel.send(b'samekey!' + struct.pack('<I', index) + b'c' * 28, False)
|
||||
index += 1
|
||||
await chatter_reply.wait()
|
||||
await asyncio.sleep(.005)
|
||||
|
||||
async def receiver():
|
||||
while True:
|
||||
if await channel.receive() == b't' * 40:
|
||||
recovered.set()
|
||||
else:
|
||||
chatter_reply.set()
|
||||
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .05), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .12), \
|
||||
patch('proxy.cf_h2.REPLAY_HISTORY_PACKETS', 3):
|
||||
await channel.send(body, False)
|
||||
self.tasks.extend(asyncio.create_task(coro) for coro in (
|
||||
server(), chatter(), receiver(), channel._recover()))
|
||||
await asyncio.wait_for(recovered.wait(), .6)
|
||||
self.assertGreater(chatter_requests, 3)
|
||||
self.assertLessEqual(len(channel.replay_history), 3)
|
||||
self.assertIn(original[1], self.transport.connection.streams)
|
||||
self.assertTrue(self.resets.empty())
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_receiving_other_body_does_not_mask_overdue_request(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'parallel-body-recovery-test')
|
||||
body = b'samekey!' + b't' * 32
|
||||
await channel.send(body, False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
await channel.send(b'samekey!' + b'b' * 32, False)
|
||||
other = await asyncio.wait_for(self.requests.get(), 1)
|
||||
self.respond(other, b'b' * 4, end=False, length=400)
|
||||
|
||||
async def body_progress():
|
||||
for _ in range(90):
|
||||
await asyncio.sleep(.01)
|
||||
conn, sid, _ = other
|
||||
conn.h2.send_data(sid, b'b' * 4)
|
||||
self.flush(conn)
|
||||
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .03), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .07):
|
||||
self.tasks.extend(asyncio.create_task(coro) for coro in (body_progress(), channel._recover()))
|
||||
probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(probe[2], body)
|
||||
self.assertEqual(channel.receiving_http, 1)
|
||||
self.respond(probe, b't' * 40)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b't' * 40)
|
||||
self.assertIn(original[1], self.transport.connection.streams)
|
||||
self.assertIn(other[1], self.transport.connection.streams)
|
||||
self.assertTrue(self.resets.empty())
|
||||
|
||||
async def test_silent_recovery_polls_refresh_without_new_native_input(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'refresh-recovery-test')
|
||||
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)]
|
||||
for body in bodies:
|
||||
await channel.send(body, False)
|
||||
await asyncio.wait_for(self.requests.get(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .2), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
latest = await asyncio.wait_for(self.requests.get(), .5)
|
||||
older = await asyncio.wait_for(self.requests.get(), .5)
|
||||
channel.sent_since[channel.replay_history[0].pending] = time.monotonic() - 1
|
||||
refreshed = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(refreshed[2], bodies[0])
|
||||
self.assertEqual(latest[2], bodies[0])
|
||||
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (latest[1], 8))
|
||||
self.assertIn(older[1], self.transport.connection.streams)
|
||||
self.assertEqual(len(channel.replay_pending), 2)
|
||||
self.respond(refreshed)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
|
||||
self.assertEqual(channel.replay_rotations, 1)
|
||||
|
||||
async def test_recovery_never_resets_replays_with_partially_received_bodies(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'protected-body-test')
|
||||
bodies = [b'k' * 8 + bytes([index]) * 32 for index in range(3)]
|
||||
for body in bodies[:2]:
|
||||
await channel.send(body, False)
|
||||
await asyncio.wait_for(self.requests.get(), 1)
|
||||
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .04):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
self.tasks.append(recovery)
|
||||
probes = [await asyncio.wait_for(self.requests.get(), 1) for _ in range(2)]
|
||||
for probe in probes:
|
||||
self.respond(probe, b'p' * 20, end=False, length=40)
|
||||
await channel.send(bodies[2], False)
|
||||
original = await asyncio.wait_for(self.requests.get(), 1)
|
||||
await asyncio.sleep(.1)
|
||||
self.assertEqual(channel.receiving_http, 2)
|
||||
self.assertTrue(self.requests.empty())
|
||||
self.assertTrue(self.resets.empty())
|
||||
self.assertIn(original[1], self.transport.connection.streams)
|
||||
# Finishing a partial response frees a slot without throwing its
|
||||
# MTProto result away, and recovery can then use that slot.
|
||||
conn, sid, _ = probes[0]
|
||||
conn.h2.send_data(sid, b'p' * 20, end_stream=True)
|
||||
self.flush(conn)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'p' * 40)
|
||||
fresh_probe = await asyncio.wait_for(self.requests.get(), .5)
|
||||
self.assertEqual(fresh_probe[2], bodies[2])
|
||||
self.respond(fresh_probe)
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
|
||||
self.assertEqual(channel.replay_rotations, 0)
|
||||
|
||||
async def test_preflight_and_posts_use_same_tls_connection(self):
|
||||
preflight = asyncio.create_task(self.lane._preflight())
|
||||
self.tasks.append(preflight)
|
||||
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||
self.respond(request, b'', status=501)
|
||||
await preflight
|
||||
task, request = await self.post()
|
||||
self.respond(request)
|
||||
await task
|
||||
self.assertEqual(self.lane.tcp_connections, 1)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_concurrent_waiters_respect_remote_stream_limit(self):
|
||||
self.server_max_streams = 2
|
||||
pending = [asyncio.create_task(self.lane._post(b'x' * 40, i)) for i in range(12)]
|
||||
self.tasks.extend(pending)
|
||||
for _ in range(6):
|
||||
batch = [await asyncio.wait_for(self.requests.get(), 2) for _ in range(2)]
|
||||
await asyncio.sleep(.01)
|
||||
self.assertTrue(self.requests.empty())
|
||||
for request in batch:
|
||||
self.respond(request)
|
||||
self.assertEqual(await asyncio.gather(*pending), [b'r' * 40] * 12)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_cancellation_resets_stream_and_reuses_server_capacity(self):
|
||||
self.server_max_streams = 2
|
||||
healthy, healthy_request = await self.post()
|
||||
for _ in range(5):
|
||||
task, request = await self.post()
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||
self.respond(healthy_request)
|
||||
self.assertEqual(await asyncio.wait_for(healthy, .5), b'r' * 40)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_cancel_while_waiting_for_stream_slot_does_not_leak_capacity(self):
|
||||
self.server_max_streams = 1
|
||||
first, request = await self.post()
|
||||
waiting = asyncio.create_task(self.lane._post(b'w' * 40, 2))
|
||||
self.tasks.append(waiting)
|
||||
await asyncio.sleep(.03)
|
||||
self.assertTrue(self.requests.empty())
|
||||
waiting.cancel()
|
||||
await asyncio.gather(waiting, return_exceptions=True)
|
||||
self.respond(request)
|
||||
await first
|
||||
next_task, next_request = await self.post()
|
||||
self.respond(next_request)
|
||||
await next_task
|
||||
self.assertFalse(self.transport.connection.streams)
|
||||
|
||||
async def test_large_upload_and_download_cross_flow_control_windows(self):
|
||||
body = b'x' * (256 * 1024)
|
||||
task, request = await self.post(body)
|
||||
self.respond(request, body)
|
||||
self.assertEqual(await asyncio.wait_for(task, 2), body)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_stalled_body_consumer_does_not_block_other_streams(self):
|
||||
# Keep a streaming response open without reading it. Its window must
|
||||
# bound buffering while other responses can exceed a connection window.
|
||||
opening = asyncio.create_task(self.lane.client.send(
|
||||
self.lane.client.build_request('POST', 'https://' + self.lane.host + '/api', content=b'x' * 40),
|
||||
stream=True))
|
||||
self.tasks.append(opening)
|
||||
request = await asyncio.wait_for(self.requests.get(), 2)
|
||||
self.respond(request, b's' * (1024 * 1024))
|
||||
response = await opening
|
||||
fast, fast_request = await self.post()
|
||||
self.respond(fast_request, b'f' * (128 * 1024))
|
||||
self.assertEqual(await asyncio.wait_for(fast, .5), b'f' * (128 * 1024))
|
||||
stream = self.transport.connection.streams[request[1]]
|
||||
self.assertLessEqual(sum(len(data) for data, _ in stream.chunks), STREAM_RECEIVE_WINDOW)
|
||||
await response.aclose()
|
||||
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||
|
||||
async def test_read_timeout_resets_only_its_stream(self):
|
||||
self.lane.client.timeout = httpx.Timeout(2, read=.15)
|
||||
stalled, request = await self.post()
|
||||
healthy, healthy_request = await self.post()
|
||||
self.respond(healthy_request)
|
||||
self.assertEqual(await healthy, b'r' * 40)
|
||||
with self.assertRaises(httpx.ReadTimeout):
|
||||
await stalled
|
||||
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||
self.assertIsNone(self.transport.connection.error)
|
||||
|
||||
async def test_peer_reset_does_not_break_other_streams(self):
|
||||
bad, request = await self.post()
|
||||
good, good_request = await self.post()
|
||||
conn, sid, _ = request
|
||||
conn.h2.reset_stream(sid, error_code=8)
|
||||
self.flush(conn)
|
||||
with self.assertRaises(httpx.RemoteProtocolError):
|
||||
await bad
|
||||
self.respond(good_request)
|
||||
self.assertEqual(await good, b'r' * 40)
|
||||
|
||||
async def test_connection_loss_fails_waiters_and_next_request_reconnects(self):
|
||||
tasks = [await self.post() for _ in range(3)]
|
||||
self.connections[0].writer.close()
|
||||
for task, _ in tasks:
|
||||
with self.assertRaises(httpx.HTTPError):
|
||||
await asyncio.wait_for(task, .5)
|
||||
task, request = await self.post()
|
||||
self.respond(request)
|
||||
self.assertEqual(await task, b'r' * 40)
|
||||
self.assertEqual(len(self.connections), 2)
|
||||
|
||||
async def test_goaway_fails_pending_without_replaying_and_reconnects(self):
|
||||
task, request = await self.post()
|
||||
conn, _, _ = request
|
||||
conn.h2.close_connection(error_code=0)
|
||||
self.flush(conn)
|
||||
with self.assertRaisesRegex(httpx.RemoteProtocolError, 'GOAWAY'):
|
||||
await task
|
||||
self.assertTrue(self.requests.empty())
|
||||
next_task, next_request = await self.post()
|
||||
self.respond(next_request)
|
||||
await next_task
|
||||
self.assertEqual(len(self.connections), 2)
|
||||
|
||||
async def test_close_stops_reader_and_wakes_pending_requests(self):
|
||||
task, _ = await self.post()
|
||||
connection = self.transport.connection
|
||||
await self.lane.close()
|
||||
with self.assertRaises(httpx.HTTPError):
|
||||
await asyncio.wait_for(task, .5)
|
||||
self.assertTrue(connection.reader_task.done())
|
||||
self.assertTrue(connection.writer.is_closing())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,245 @@
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from proxy.config import proxy_config
|
||||
from proxy.pool import _WsPool
|
||||
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
|
||||
from proxy.utils import ws_domains
|
||||
|
||||
|
||||
class _StopRotation(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _open_ws():
|
||||
transport = SimpleNamespace(is_closing=lambda: False)
|
||||
writer = mock.Mock(transport=transport, drain=mock.AsyncMock(),
|
||||
wait_closed=mock.AsyncMock())
|
||||
return RawWebSocket(asyncio.StreamReader(), writer)
|
||||
|
||||
|
||||
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_warmup_preserves_media_ws_with_or_without_h2(self):
|
||||
for cf, secure, opted_out, media in [(True, True, False, True),
|
||||
(False, True, False, True),
|
||||
(True, False, False, True),
|
||||
(True, True, True, True)]:
|
||||
with self.subTest(cf=cf, secure=secure, opted_out=opted_out), \
|
||||
mock.patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.220'}), \
|
||||
mock.patch.object(proxy_config, 'fallback_cfproxy', cf), \
|
||||
mock.patch.object(proxy_config, 'disable_secure', not secure), \
|
||||
mock.patch.object(proxy_config, 'cfproxy_h2_media', not opted_out), \
|
||||
mock.patch.object(proxy_config, 'force_test_dc', False):
|
||||
pool = _WsPool()
|
||||
with mock.patch.object(pool, '_schedule_refill') as refill:
|
||||
await pool.warmup()
|
||||
keys = [call.args[0] for call in refill.call_args_list]
|
||||
self.assertIn((2, False, False), keys)
|
||||
self.assertEqual((2, True, False) in keys, media)
|
||||
|
||||
async def test_refills_partially_populated_bucket(self):
|
||||
pool = _WsPool()
|
||||
key = (2, False, False)
|
||||
pool._idle[key] = deque([
|
||||
(_open_ws(), time.monotonic()),
|
||||
(_open_ws(), time.monotonic()),
|
||||
])
|
||||
async def stop_after_one_iteration(_delay):
|
||||
raise _StopRotation
|
||||
|
||||
with mock.patch.object(proxy_config, 'pool_size', 4):
|
||||
with mock.patch(
|
||||
'proxy.pool.asyncio.sleep',
|
||||
side_effect=stop_after_one_iteration):
|
||||
with mock.patch.object(
|
||||
pool, '_schedule_refill') as schedule_refill:
|
||||
with self.assertRaises(_StopRotation):
|
||||
await pool._rotate(
|
||||
key, '149.154.167.220', ['example.com'])
|
||||
|
||||
schedule_refill.assert_called_once_with(
|
||||
key, '149.154.167.220', ['example.com'])
|
||||
|
||||
|
||||
class WsPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.pool = _WsPool()
|
||||
self.key = (2, False, False)
|
||||
self.pool.WS_POOL_CHECK_INTERVAL = .01
|
||||
for name, value in [('dc_redirects', {2: '192.0.2.1'}),
|
||||
('pool_size', 1), ('force_test_dc', False)]:
|
||||
patcher = mock.patch.object(proxy_config, name, value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addAsyncCleanup(self.pool.close)
|
||||
|
||||
async def wait_for(self, predicate):
|
||||
async def wait():
|
||||
while not predicate():
|
||||
await asyncio.sleep(.001)
|
||||
await asyncio.wait_for(wait(), 1)
|
||||
|
||||
async def test_regular_sessions_never_use_media_domain(self):
|
||||
for dc in (2, 4):
|
||||
with self.subTest(dc=dc), mock.patch(
|
||||
'proxy.pool.RawWebSocket.connect',
|
||||
side_effect=WsHandshakeError(302, 'redirect')) as connect:
|
||||
self.assertIsNone(await self.pool._connect_one('192.0.2.1', ws_domains(dc, False)))
|
||||
self.assertEqual([call.args[1] for call in connect.call_args_list],
|
||||
[f'kws{dc}.web.telegram.org'] * 2)
|
||||
|
||||
async def test_media_tries_regular_domain_after_both_modes_fail(self):
|
||||
for error in (asyncio.TimeoutError(), ConnectionResetError(),
|
||||
WsHandshakeError(302, 'redirect'), WsHandshakeError(500, 'error')):
|
||||
with self.subTest(error=type(error)), mock.patch(
|
||||
'proxy.pool.RawWebSocket.connect',
|
||||
side_effect=[error, error, _open_ws()]) as connect:
|
||||
self.assertIsNotNone(await self.pool._connect_one('192.0.2.1', ws_domains(2, True)))
|
||||
self.assertEqual([call.args[1] for call in connect.call_args_list],
|
||||
['kws2-1.web.telegram.org'] * 2 + ['kws2.web.telegram.org'])
|
||||
|
||||
async def test_concurrent_preference_change_does_not_skip_fronting(self):
|
||||
ws = _open_ws()
|
||||
|
||||
async def connect(*args, **kwargs):
|
||||
if kwargs['sni'] is None:
|
||||
self.pool.try_fronting_first = True
|
||||
raise asyncio.TimeoutError()
|
||||
return ws
|
||||
|
||||
with mock.patch('proxy.pool.RawWebSocket.connect', side_effect=connect) as dial:
|
||||
self.assertIs(await self.pool._connect_one('192.0.2.1', ws_domains(2, False)), ws)
|
||||
self.assertEqual(dial.await_count, 2)
|
||||
|
||||
async def test_miss_returns_without_waiting_and_starts_only_one_refill(self):
|
||||
started = asyncio.Event()
|
||||
|
||||
async def connect(*args):
|
||||
started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
with mock.patch.object(self.pool, '_connect_one', side_effect=connect) as dial:
|
||||
for _ in range(10):
|
||||
self.assertIsNone(await self.pool.get(2, False))
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
self.assertEqual(dial.await_count, 1)
|
||||
await self.pool.close()
|
||||
|
||||
async def test_empty_pool_recovers_in_background_after_backoff(self):
|
||||
ws = _open_ws()
|
||||
self.pool.REFILL_BACKOFF_INITIAL = .02
|
||||
with mock.patch.object(self.pool, '_connect_one',
|
||||
side_effect=[None, ws]) as dial:
|
||||
self.assertIsNone(await self.pool.get(2, False))
|
||||
await self.wait_for(lambda: self.key in self.pool._refill_after)
|
||||
self.assertEqual(dial.await_count, 1)
|
||||
# No new client requests are needed to resume refilling.
|
||||
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
|
||||
self.assertEqual(dial.await_count, 2)
|
||||
self.assertNotIn(self.key, self.pool._refill_failures)
|
||||
await self.pool.close()
|
||||
|
||||
async def test_ready_connection_is_available_before_slower_attempt(self):
|
||||
ready = _open_ws()
|
||||
slow_started = asyncio.Event()
|
||||
calls = 0
|
||||
|
||||
async def connect(*args):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
slow_started.set()
|
||||
await asyncio.Future()
|
||||
return ready
|
||||
|
||||
with mock.patch.object(proxy_config, 'pool_size', 2), \
|
||||
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
|
||||
await self.pool.get(2, False)
|
||||
await asyncio.wait_for(slow_started.wait(), 1)
|
||||
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
|
||||
self.assertIs(await self.pool.get(2, False), ready)
|
||||
await self.pool.close()
|
||||
|
||||
async def test_stale_connections_are_skipped(self):
|
||||
for state in ('expired', 'eof', 'exception', 'closed', 'closing'):
|
||||
with self.subTest(state=state):
|
||||
stale, good = _open_ws(), _open_ws()
|
||||
created = time.monotonic()
|
||||
if state == 'expired':
|
||||
created -= self.pool.WS_POOL_MAX_AGE
|
||||
elif state == 'eof':
|
||||
stale.reader.feed_eof()
|
||||
elif state == 'exception':
|
||||
stale.reader.set_exception(ConnectionResetError())
|
||||
elif state == 'closed':
|
||||
stale._closed = True
|
||||
else:
|
||||
stale.writer.transport.is_closing = lambda: True
|
||||
self.pool._idle[self.key] = deque([(stale, created), (good, time.monotonic())])
|
||||
with mock.patch.object(self.pool, '_schedule_refill'):
|
||||
self.assertIs(await self.pool.get(2, False), good)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def test_hit_does_not_reset_failed_refill_backoff(self):
|
||||
self.pool._idle[self.key] = deque([(_open_ws(), time.monotonic())])
|
||||
self.pool._refill_failures[self.key] = 3
|
||||
self.pool._refill_after[self.key] = time.monotonic() + 60
|
||||
with mock.patch.object(self.pool, '_schedule_rotation'):
|
||||
self.assertIsNotNone(await self.pool.get(2, False))
|
||||
self.assertEqual(self.pool._refill_failures[self.key], 3)
|
||||
self.assertFalse(self.pool._refilling)
|
||||
|
||||
async def test_zero_size_and_unconfigured_dc_do_not_connect(self):
|
||||
with mock.patch.object(self.pool, '_connect_one') as dial:
|
||||
self.assertIsNone(await self.pool.get(4, False))
|
||||
with mock.patch.object(proxy_config, 'pool_size', 0):
|
||||
await self.pool.warmup()
|
||||
self.assertIsNone(await self.pool.get(2, False))
|
||||
self.assertFalse(self.pool._rotating)
|
||||
self.assertFalse(self.pool._refilling)
|
||||
dial.assert_not_called()
|
||||
|
||||
async def test_test_dc_has_separate_pool_and_path(self):
|
||||
prod = _open_ws()
|
||||
self.pool._idle[self.key] = deque([(prod, time.monotonic())])
|
||||
with mock.patch.object(self.pool, '_connect_one', return_value=_open_ws()) as dial:
|
||||
self.assertIsNone(await self.pool.get(2, False, is_test_dc=True))
|
||||
test_key = (2, False, True)
|
||||
await self.wait_for(lambda: bool(self.pool._idle.get(test_key)))
|
||||
dial.assert_awaited_once_with('192.0.2.1', ['kws2.web.telegram.org'], '/apiws_test')
|
||||
self.assertIs(self.pool._idle[self.key][0][0], prod)
|
||||
await self.pool.close()
|
||||
|
||||
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
|
||||
ready = _open_ws()
|
||||
idle = _open_ws()
|
||||
started = asyncio.Event()
|
||||
self.pool._idle[self.key] = deque([(idle, time.monotonic())])
|
||||
|
||||
async def connect(*args):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
# Completion raced with shutdown; it must not leak into the pool.
|
||||
return ready
|
||||
|
||||
with mock.patch.object(proxy_config, 'pool_size', 2), \
|
||||
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
|
||||
self.pool._schedule_refill(self.key, '192.0.2.1', ws_domains(2, False))
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
await self.pool.close()
|
||||
self.assertFalse(self.pool._idle)
|
||||
self.assertFalse(self.pool._rotating)
|
||||
self.assertFalse(self.pool._refilling)
|
||||
ready.writer.close.assert_called_once()
|
||||
idle.writer.close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
|
||||
|
||||
@@ -126,5 +127,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()
|
||||
@@ -0,0 +1,109 @@
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from proxy import bridge
|
||||
|
||||
|
||||
class TcpFallbackTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
bridge.reset_tcp_backoff()
|
||||
self.addCleanup(bridge.reset_tcp_backoff)
|
||||
self.key = ('192.0.2.1', 443)
|
||||
self.remote = Mock(drain=AsyncMock(), wait_closed=AsyncMock())
|
||||
|
||||
async def fallback(self, dst='192.0.2.1', port=443):
|
||||
return await bridge._tcp_fallback(
|
||||
Mock(), Mock(), dst, port, b'init', 'test', Mock())
|
||||
|
||||
async def test_failure_backoff_doubles_caps_and_skips_new_connections(self):
|
||||
with patch.object(bridge.asyncio, 'open_connection',
|
||||
AsyncMock(side_effect=asyncio.TimeoutError())) as connect:
|
||||
for delay in (30, 60, 120, 240, 480, 960, 1920, 3600, 3600):
|
||||
bridge._tcp_retry_after[self.key] = 0
|
||||
start = time.monotonic()
|
||||
self.assertFalse(await self.fallback())
|
||||
remaining = bridge._tcp_retry_after[self.key] - start
|
||||
self.assertGreaterEqual(remaining, delay - .001)
|
||||
self.assertLess(remaining, delay + 1)
|
||||
count = connect.await_count
|
||||
self.assertFalse(await self.fallback())
|
||||
self.assertEqual(connect.await_count, count)
|
||||
self.assertFalse(bridge._tcp_connecting)
|
||||
|
||||
async def test_other_ips_and_ports_are_not_blocked(self):
|
||||
bridge._tcp_retry_after[self.key] = time.monotonic() + 60
|
||||
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
|
||||
return_value=(Mock(), self.remote))) as connect, \
|
||||
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
|
||||
self.assertTrue(await self.fallback('192.0.2.2'))
|
||||
self.assertTrue(await self.fallback(port=80))
|
||||
self.assertEqual(connect.await_count, 2)
|
||||
|
||||
async def test_success_clears_backoff_and_allows_following_sessions(self):
|
||||
bridge._tcp_failures[self.key] = 5
|
||||
bridge._tcp_retry_after[self.key] = 0
|
||||
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
|
||||
return_value=(Mock(), self.remote))) as connect, \
|
||||
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()):
|
||||
self.assertTrue(await self.fallback())
|
||||
self.assertNotIn(self.key, bridge._tcp_failures)
|
||||
self.assertNotIn(self.key, bridge._tcp_retry_after)
|
||||
self.assertTrue(await self.fallback())
|
||||
self.assertEqual(connect.await_count, 2)
|
||||
|
||||
async def test_concurrent_requests_only_start_one_connection_attempt(self):
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def connect(*args):
|
||||
started.set()
|
||||
await release.wait()
|
||||
raise ConnectionRefusedError()
|
||||
|
||||
with patch.object(bridge.asyncio, 'open_connection', side_effect=connect) as dial:
|
||||
first = asyncio.create_task(self.fallback())
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
results = await asyncio.gather(*(self.fallback() for _ in range(8)))
|
||||
self.assertEqual(results, [False] * 8)
|
||||
self.assertEqual(dial.await_count, 1)
|
||||
release.set()
|
||||
self.assertFalse(await first)
|
||||
finally:
|
||||
first.cancel()
|
||||
await asyncio.gather(first, return_exceptions=True)
|
||||
|
||||
async def test_init_failure_closes_socket_and_enters_backoff(self):
|
||||
self.remote.drain.side_effect = ConnectionResetError()
|
||||
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
|
||||
return_value=(Mock(), self.remote))), \
|
||||
patch.object(bridge, '_bridge_tcp_reencrypt', AsyncMock()) as forward:
|
||||
self.assertFalse(await self.fallback())
|
||||
self.remote.close.assert_called_once()
|
||||
forward.assert_not_awaited()
|
||||
self.assertIn(self.key, bridge._tcp_retry_after)
|
||||
|
||||
async def test_cancellation_releases_attempt_without_marking_ip_blocked(self):
|
||||
started = asyncio.Event()
|
||||
|
||||
async def drain():
|
||||
started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
self.remote.drain.side_effect = drain
|
||||
with patch.object(bridge.asyncio, 'open_connection', AsyncMock(
|
||||
return_value=(Mock(), self.remote))):
|
||||
task = asyncio.create_task(self.fallback())
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
self.remote.close.assert_called_once()
|
||||
self.assertFalse(bridge._tcp_connecting)
|
||||
self.assertFalse(bridge._tcp_failures)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
|
||||
log = logging.getLogger("tg-ws-tray")
|
||||
|
||||
|
||||
class BackgroundTask:
|
||||
def __init__(self, owner):
|
||||
self.owner = owner
|
||||
self.running = False
|
||||
self.closed = False
|
||||
self.pending = None
|
||||
owner.bind("<Destroy>", self._on_destroy, add="+")
|
||||
|
||||
def _on_destroy(self, event):
|
||||
if event.widget is self.owner:
|
||||
self.closed = True
|
||||
if self.pending is not None:
|
||||
self.owner.after_cancel(self.pending)
|
||||
self.pending = None
|
||||
|
||||
def start(self, work, complete, finish):
|
||||
if self.running or self.closed:
|
||||
return
|
||||
self.running = True
|
||||
results = queue.Queue()
|
||||
|
||||
def run():
|
||||
try:
|
||||
results.put((True, work()))
|
||||
except Exception:
|
||||
log.exception("Connectivity test failed")
|
||||
results.put((False, None))
|
||||
|
||||
def poll():
|
||||
self.pending = None
|
||||
if self.closed:
|
||||
return
|
||||
try:
|
||||
success, result = results.get_nowait()
|
||||
except queue.Empty:
|
||||
self.pending = self.owner.after(50, poll)
|
||||
return
|
||||
self.running = False
|
||||
try:
|
||||
if success:
|
||||
complete(result)
|
||||
finally:
|
||||
if not self.closed:
|
||||
finish()
|
||||
|
||||
threading.Thread(target=run, daemon=True, name="connectivity-test").start()
|
||||
self.pending = self.owner.after(50, poll)
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import socket as _socket
|
||||
from contextlib import nullcontext
|
||||
from tkinter import messagebox
|
||||
from typing import Any
|
||||
|
||||
from proxy.utils import create_ssl_context
|
||||
from ui.i18n import t
|
||||
|
||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
||||
_CFWORKER_TEST_DST = {
|
||||
1: '149.154.175.50',
|
||||
2: '149.154.167.51',
|
||||
3: '149.154.175.100',
|
||||
4: '149.154.167.91',
|
||||
5: '149.154.171.5',
|
||||
203: '91.105.192.100',
|
||||
}
|
||||
|
||||
|
||||
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
|
||||
ctx = create_ssl_context() if secure else None
|
||||
port = 443 if secure else 80
|
||||
results = {}
|
||||
for dc, connect_host, sni_host, req_host, path in cases:
|
||||
try:
|
||||
with _socket.create_connection((connect_host, port), timeout=5) as raw:
|
||||
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
|
||||
if secure else nullcontext(raw))
|
||||
with connection as ssock:
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {req_host}\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"Sec-WebSocket-Protocol: binary\r\n"
|
||||
f"\r\n"
|
||||
).encode()
|
||||
ssock.sendall(req)
|
||||
ssock.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = ssock.recv(512)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
||||
if "101" in first:
|
||||
results[dc] = True
|
||||
else:
|
||||
results[dc] = first or t("connectivity.no_response")
|
||||
ssock.close()
|
||||
raw.close()
|
||||
except _socket.timeout:
|
||||
results[dc] = t("connectivity.timeout")
|
||||
except OSError as exc:
|
||||
msg = str(exc)
|
||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
||||
return results
|
||||
|
||||
|
||||
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
host = f"kws{dc}.{domain}"
|
||||
cases.append((dc, host, host, host, "/apiws"))
|
||||
return _run_connectivity_test(cases, secure=secure)
|
||||
|
||||
|
||||
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
dst = _CFWORKER_TEST_DST[dc]
|
||||
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
||||
cases.append((dc, domain, domain, domain, path))
|
||||
return _run_connectivity_test(cases, secure=secure)
|
||||
|
||||
|
||||
def run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
|
||||
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
|
||||
|
||||
|
||||
def run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
|
||||
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
|
||||
|
||||
|
||||
def run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
|
||||
merged: dict = {}
|
||||
best_domain = None
|
||||
for domain in reversed(domains):
|
||||
res = _run_cfproxy_connectivity_test(domain, secure=secure)
|
||||
if all(v is True for v in res.values()):
|
||||
return domain, res
|
||||
for dc, v in res.items():
|
||||
if v is True:
|
||||
merged[dc] = True
|
||||
best_domain = domain
|
||||
elif dc not in merged:
|
||||
merged[dc] = v
|
||||
return best_domain, merged
|
||||
|
||||
|
||||
def show_connectivity_results(title_base: str, results: dict,
|
||||
domain: str = '', label_prefix: str = 'DC',
|
||||
auto_mode: bool = False,
|
||||
unavailable_message: str = '', *, parent: Any) -> None:
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
total = len(_CFPROXY_TEST_DCS)
|
||||
if auto_mode:
|
||||
if domain:
|
||||
title = t("connectivity.available", title=title_base)
|
||||
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
|
||||
else:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
msg = unavailable_message
|
||||
else:
|
||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||
if len(ok) == total:
|
||||
title = t("connectivity.all_ok", title=title_base)
|
||||
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
|
||||
elif not ok:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
errors = "\n".join(
|
||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||
for dc, v in fail
|
||||
)
|
||||
msg = t("connectivity.none_ok", domain=domain, errors=errors)
|
||||
else:
|
||||
title = t("connectivity.partial", title=title_base)
|
||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
||||
fail_list = "\n".join(
|
||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
||||
for dc, v in fail
|
||||
)
|
||||
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||
|
||||
messagebox.showinfo(title, msg, parent=parent)
|
||||
|
||||
|
||||
def show_multi_connectivity_results(title_base: str, per_domain: dict,
|
||||
label_prefix: str = 'DC', *, parent: Any) -> None:
|
||||
total = len(_CFPROXY_TEST_DCS)
|
||||
all_ok = True
|
||||
any_ok = False
|
||||
blocks = []
|
||||
for domain, results in per_domain.items():
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||
if len(ok) == total:
|
||||
any_ok = True
|
||||
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
|
||||
elif not ok:
|
||||
all_ok = False
|
||||
blocks.append(t("connectivity.multi_fail", domain=domain))
|
||||
else:
|
||||
all_ok = False
|
||||
any_ok = True
|
||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
||||
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
|
||||
blocks.append(
|
||||
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||
)
|
||||
|
||||
if all_ok:
|
||||
title = t("connectivity.all_ok", title=title_base)
|
||||
elif any_ok:
|
||||
title = t("connectivity.partial", title=title_base)
|
||||
else:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
msg = "\n\n".join(blocks)
|
||||
|
||||
messagebox.showinfo(title, msg, parent=parent)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ui.ctk_theme import CtkTheme
|
||||
from ui.ctk_tooltip import attach_tooltip_to_widgets
|
||||
|
||||
|
||||
def create_entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
||||
opts = {
|
||||
"font": (theme.ui_font_family, 13), "corner_radius": radius,
|
||||
"fg_color": theme.bg, "border_color": theme.field_border,
|
||||
"border_width": 1, "text_color": theme.text_primary,
|
||||
}
|
||||
if var is not None:
|
||||
opts["textvariable"] = var
|
||||
if width:
|
||||
opts["width"] = width
|
||||
opts["height"] = height
|
||||
opts.update(kw)
|
||||
return ctk.CTkEntry(parent, **opts)
|
||||
|
||||
|
||||
def create_checkbox(ctk, parent, theme, text, variable):
|
||||
return ctk.CTkCheckBox(
|
||||
parent, text=text, variable=variable,
|
||||
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
corner_radius=6, border_width=2, border_color=theme.field_border,
|
||||
)
|
||||
|
||||
|
||||
def create_label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
|
||||
weight = "bold" if bold else "normal"
|
||||
return ctk.CTkLabel(
|
||||
parent, text=text,
|
||||
font=(theme.ui_font_family, size, weight),
|
||||
text_color=theme.text_secondary if secondary else theme.text_primary,
|
||||
anchor="w", **kw,
|
||||
)
|
||||
|
||||
|
||||
def create_labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
|
||||
col = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
lbl = create_label(ctk, col, theme, label_text)
|
||||
lbl.pack(anchor="w", pady=(0, 2))
|
||||
var = ctk.StringVar(master=parent, value=str(value))
|
||||
ent = create_entry(ctk, col, theme, var=var, width=width)
|
||||
if pack_fill:
|
||||
ent.pack(fill="x")
|
||||
else:
|
||||
ent.pack(anchor="w")
|
||||
if tip:
|
||||
attach_tooltip_to_widgets([lbl, ent, col], tip)
|
||||
return col, var
|
||||
|
||||
|
||||
def create_config_section(
|
||||
ctk: Any,
|
||||
parent: Any,
|
||||
theme: CtkTheme,
|
||||
title: str,
|
||||
*,
|
||||
bottom_spacer: int = 6,
|
||||
) -> Any:
|
||||
wrap = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
wrap.pack(fill="x", pady=(0, bottom_spacer))
|
||||
create_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
|
||||
card = ctk.CTkFrame(
|
||||
wrap, fg_color=theme.field_bg, corner_radius=10,
|
||||
border_width=1, border_color=theme.field_border,
|
||||
)
|
||||
card.pack(fill="x")
|
||||
inner = ctk.CTkFrame(card, fg_color="transparent")
|
||||
inner.pack(fill="x", padx=10, pady=8)
|
||||
return inner
|
||||
|
||||
|
||||
def create_combobox(ctk, parent, theme, *, variable, values, command=None):
|
||||
return ctk.CTkComboBox(
|
||||
parent, values=values, variable=variable, command=command,
|
||||
height=32, font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_primary, fg_color=theme.bg,
|
||||
border_color=theme.field_border, button_color=theme.field_border,
|
||||
button_hover_color=theme.text_secondary,
|
||||
dropdown_fg_color=theme.field_bg,
|
||||
dropdown_text_color=theme.text_primary,
|
||||
dropdown_hover_color=theme.field_border,
|
||||
corner_radius=8, state="readonly",
|
||||
)
|
||||
+11
-891
@@ -1,303 +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) -> dict:
|
||||
import base64
|
||||
import ssl
|
||||
import socket as _socket
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
results = {}
|
||||
for dc, connect_host, sni_host, req_host, path in cases:
|
||||
try:
|
||||
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {req_host}\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"Sec-WebSocket-Protocol: binary\r\n"
|
||||
f"\r\n"
|
||||
).encode()
|
||||
ssock.sendall(req)
|
||||
ssock.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = ssock.recv(512)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
||||
if "101" in first:
|
||||
results[dc] = True
|
||||
else:
|
||||
results[dc] = first or 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) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
host = f"kws{dc}.{domain}"
|
||||
cases.append((dc, host, host, host, "/apiws"))
|
||||
return _run_connectivity_test(cases)
|
||||
|
||||
|
||||
def _run_cfworker_connectivity_test(domain: str) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
dst = _CFWORKER_TEST_DST[dc]
|
||||
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
||||
cases.append((dc, domain, domain, domain, path))
|
||||
return _run_connectivity_test(cases)
|
||||
|
||||
|
||||
def _run_cfproxy_multi_test(domains: list) -> dict:
|
||||
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
|
||||
|
||||
|
||||
def _run_cfworker_multi_test(domains: list) -> dict:
|
||||
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
|
||||
|
||||
|
||||
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
||||
merged: dict = {}
|
||||
best_domain = None
|
||||
for domain in reversed(domains):
|
||||
res = _run_cfproxy_connectivity_test(domain)
|
||||
if all(v is True for v in res.values()):
|
||||
return domain, res
|
||||
for dc, v in res.items():
|
||||
if v is True:
|
||||
merged[dc] = True
|
||||
best_domain = domain
|
||||
elif dc not in merged:
|
||||
merged[dc] = v
|
||||
return best_domain, merged
|
||||
|
||||
|
||||
def _show_connectivity_results(title_base: str, results: dict,
|
||||
domain: str = '', label_prefix: str = 'DC',
|
||||
auto_mode: bool = False,
|
||||
unavailable_message: str = '') -> None:
|
||||
import tkinter as _tk
|
||||
from tkinter import messagebox as _mb
|
||||
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
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(
|
||||
@@ -322,600 +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
|
||||
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,
|
||||
) -> 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():
|
||||
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)
|
||||
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)
|
||||
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():
|
||||
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)
|
||||
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"))
|
||||
|
||||
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
|
||||
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,
|
||||
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
|
||||
return new_cfg
|
||||
|
||||
|
||||
def install_tray_config_buttons(
|
||||
ctk: Any,
|
||||
frame: Any,
|
||||
@@ -1017,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():
|
||||
|
||||
+8
-3
@@ -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",
|
||||
@@ -32,6 +34,7 @@
|
||||
"label.cf_custom_domain": "Custom domain",
|
||||
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
|
||||
"label.verbose": "Verbose logging",
|
||||
"label.no_secure": "Disable TLS for CF-proxy and CF-worker",
|
||||
"label.buf_kb": "Buffer, KB (default 256)",
|
||||
"label.pool_size": "WebSocket session pool (default 4)",
|
||||
"label.log_max_mb": "Max log size, MB (default 5)",
|
||||
@@ -44,13 +47,14 @@
|
||||
"tip.secret": "Secret key for client authorization",
|
||||
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
|
||||
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
|
||||
"tip.no_secure": "When enabled, the proxy will use port 80 for CF-proxy and CF-worker connections",
|
||||
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
|
||||
"tip.pool": "How many 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",
|
||||
@@ -113,11 +117,12 @@
|
||||
"tray.restart": "Restart proxy",
|
||||
"tray.settings": "Settings...",
|
||||
"tray.logs": "Open logs",
|
||||
"tray.update": "Update ({current} → {new})",
|
||||
"tray.exit": "Exit",
|
||||
|
||||
"dialog.restart_title": "Restart?",
|
||||
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
|
||||
"dialog.already_running": "Application is already running.",
|
||||
"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}",
|
||||
|
||||
+8
-3
@@ -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": "Обновления",
|
||||
@@ -32,6 +34,7 @@
|
||||
"label.cf_custom_domain": "Свой домен",
|
||||
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
|
||||
"label.verbose": "Подробное логирование (verbose)",
|
||||
"label.no_secure": "Выключить TLS для CF-прокси и CF-worker",
|
||||
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
|
||||
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
|
||||
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
|
||||
@@ -44,13 +47,14 @@
|
||||
"tip.secret": "Секретный ключ для авторизации клиентов",
|
||||
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
|
||||
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
|
||||
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
|
||||
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
|
||||
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\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": "Сохранить настройки",
|
||||
@@ -113,11 +117,12 @@
|
||||
"tray.restart": "Перезапустить прокси",
|
||||
"tray.settings": "Настройки...",
|
||||
"tray.logs": "Открыть логи",
|
||||
"tray.update": "Обновить ({current} → {new})",
|
||||
"tray.exit": "Выход",
|
||||
|
||||
"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}",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
from proxy import coerce_domain_list, parse_dc_ip_list
|
||||
from ui.i18n import t
|
||||
|
||||
UI_ONLY_KEYS = frozenset({
|
||||
"appearance", "autostart", "check_updates", "language"
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingsChange:
|
||||
config: dict
|
||||
changed_keys: frozenset
|
||||
|
||||
@property
|
||||
def requires_restart(self) -> bool:
|
||||
return bool(self.changed_keys - UI_ONLY_KEYS)
|
||||
|
||||
|
||||
def prepare_settings(current: dict, values: dict, defaults: dict) -> SettingsChange:
|
||||
config = deepcopy(defaults)
|
||||
config.update(deepcopy(current))
|
||||
changed_keys = frozenset(
|
||||
key for key, value in values.items() if value != config.get(key)
|
||||
)
|
||||
config.update(deepcopy(values))
|
||||
return SettingsChange(config, changed_keys)
|
||||
|
||||
|
||||
def validate_settings(values: dict, defaults: dict) -> Union[dict, str]:
|
||||
config = dict(values)
|
||||
|
||||
host = values["host"].strip()
|
||||
try:
|
||||
socket.inet_aton(host)
|
||||
except OSError:
|
||||
return t("validation.bad_host")
|
||||
|
||||
try:
|
||||
port = int(values["port"].strip())
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return t("validation.bad_port")
|
||||
|
||||
lines = [line.strip() for line in values["dc_ip"].splitlines() if line.strip()]
|
||||
try:
|
||||
parse_dc_ip_list(lines)
|
||||
except ValueError as exc:
|
||||
entry = getattr(exc, "entry", None)
|
||||
if entry is None:
|
||||
return str(exc)
|
||||
key = "dc_format" if getattr(exc, "kind", "invalid") == "format" else "dc_invalid"
|
||||
return t(f"validation.{key}", entry=entry)
|
||||
|
||||
secret = values["secret"].strip()
|
||||
if len(secret) != 32:
|
||||
return t("validation.bad_secret_len")
|
||||
if any(char not in "0123456789abcdefABCDEF" for char in secret):
|
||||
return t("validation.bad_secret_hex")
|
||||
|
||||
config.update(host=host, port=port, secret=secret, dc_ip=lines)
|
||||
|
||||
for key in ("buf_kb", "pool_size", "log_max_mb"):
|
||||
try:
|
||||
value = float(values[key].strip())
|
||||
if not math.isfinite(value):
|
||||
raise ValueError
|
||||
config[key] = int(value) if key != "log_max_mb" else value
|
||||
except (ValueError, OverflowError):
|
||||
config[key] = defaults[key]
|
||||
|
||||
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
|
||||
if key in config:
|
||||
config[key] = coerce_domain_list(config[key])
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
|
||||
from tkinter import messagebox
|
||||
|
||||
from ui.settings_form import validate_config_form
|
||||
from ui.i18n import set_language, t
|
||||
from ui.settings import prepare_settings
|
||||
|
||||
log = logging.getLogger("tg-ws-tray")
|
||||
|
||||
|
||||
class SettingsDialog:
|
||||
def __init__(
|
||||
self, *, ctk, root, widgets, config, defaults, persist,
|
||||
refresh_menu, finish, restart, include_autostart=False,
|
||||
apply_autostart=None,
|
||||
):
|
||||
self.ctk = ctk
|
||||
self.root = root
|
||||
self.widgets = widgets
|
||||
self.config = config
|
||||
self.defaults = defaults
|
||||
self.persist = persist
|
||||
self.refresh_menu = refresh_menu
|
||||
self.finish = finish
|
||||
self.restart = restart
|
||||
self.include_autostart = include_autostart
|
||||
self.apply_autostart = apply_autostart
|
||||
self.closed = False
|
||||
|
||||
def cancel(self):
|
||||
if self.closed:
|
||||
return
|
||||
mode = self.config.get("appearance", "auto")
|
||||
self.ctk.set_appearance_mode("system" if mode == "auto" else mode)
|
||||
set_language(self.config.get("language", self.defaults["language"]))
|
||||
self.refresh_menu()
|
||||
self._finish()
|
||||
|
||||
def _finish(self):
|
||||
self.closed = True
|
||||
self.finish()
|
||||
|
||||
def save(self):
|
||||
if self.closed:
|
||||
return
|
||||
values = validate_config_form(
|
||||
self.widgets, self.defaults, include_autostart=self.include_autostart,
|
||||
)
|
||||
if isinstance(values, str):
|
||||
messagebox.showerror(t("app.error_title"), values, parent=self.root)
|
||||
return
|
||||
change = prepare_settings(self.config, values, self.defaults)
|
||||
if not change.changed_keys:
|
||||
self._finish()
|
||||
return
|
||||
try:
|
||||
self.persist(change.config)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
log.exception("Failed to save settings")
|
||||
messagebox.showerror(t("app.error_title"), str(exc), parent=self.root)
|
||||
return
|
||||
self.config.update(deepcopy(change.config))
|
||||
set_language(change.config["language"])
|
||||
log.info("Settings saved: %s", ", ".join(sorted(change.changed_keys)))
|
||||
if self.apply_autostart is not None:
|
||||
self.apply_autostart(bool(change.config.get("autostart", False)))
|
||||
self.refresh_menu()
|
||||
do_restart = change.requires_restart and messagebox.askyesno(
|
||||
t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root,
|
||||
)
|
||||
self._finish()
|
||||
if do_restart:
|
||||
threading.Thread(
|
||||
target=self.restart, args=(deepcopy(change.config),),
|
||||
daemon=True, name="proxy-restart",
|
||||
).start()
|
||||
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from proxy import __version__, coerce_domain_list
|
||||
from proxy.balancer import balancer
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||
from ui.background_task import BackgroundTask
|
||||
from ui.connectivity import (
|
||||
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
|
||||
show_connectivity_results, show_multi_connectivity_results,
|
||||
)
|
||||
from ui.ctk_controls import (
|
||||
create_entry, create_checkbox, create_label, create_labeled_entry,
|
||||
create_config_section, create_combobox,
|
||||
)
|
||||
from ui.ctk_theme import CtkTheme
|
||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||
from ui.settings import validate_settings
|
||||
from ui.i18n import (
|
||||
label_from_language, language_from_label, language_option_labels,
|
||||
set_language, get_language, t,
|
||||
)
|
||||
|
||||
_INNER_W = 396
|
||||
_APPEARANCE_KEYS = ("auto", "light", "dark")
|
||||
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||
|
||||
|
||||
def _get_doc_url(doc_name: str) -> str:
|
||||
lang = get_language().value
|
||||
lang_folder = "EN" if lang == "en" else "RU"
|
||||
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
|
||||
|
||||
|
||||
def _appearance_options() -> List[str]:
|
||||
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
|
||||
|
||||
|
||||
def _appearance_from_cfg(value: str) -> str:
|
||||
if value in _APPEARANCE_KEYS:
|
||||
return t(f"appearance.{value}")
|
||||
return t("appearance.auto")
|
||||
|
||||
|
||||
def _appearance_to_cfg(label: str) -> str:
|
||||
for key in _APPEARANCE_KEYS:
|
||||
if t(f"appearance.{key}") == label:
|
||||
return key
|
||||
return "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrayConfigFormWidgets:
|
||||
host_var: Any
|
||||
port_var: Any
|
||||
secret_var: Any
|
||||
dc_textbox: Any
|
||||
verbose_var: Any
|
||||
no_secure_var: Any
|
||||
advanced_vars: Dict[str, Any]
|
||||
autostart_var: Optional[Any]
|
||||
check_updates_var: Optional[Any]
|
||||
cfproxy_var: Optional[Any] = None
|
||||
h2_var: Optional[Any] = None
|
||||
cfproxy_user_domain_enabled_var: Optional[Any] = None
|
||||
cfproxy_user_domain_var: Optional[Any] = None
|
||||
cfproxy_worker_enabled_var: Optional[Any] = None
|
||||
cfproxy_worker_domain_var: Optional[Any] = None
|
||||
appearance_var: Optional[Any] = None
|
||||
language_var: Optional[Any] = None
|
||||
|
||||
|
||||
def install_tray_config_form(
|
||||
ctk: Any,
|
||||
frame: Any,
|
||||
theme: CtkTheme,
|
||||
cfg: dict,
|
||||
default_config: dict,
|
||||
*,
|
||||
show_autostart: bool = False,
|
||||
autostart_value: bool = False,
|
||||
on_update_click: Optional[Callable[[], None]] = None,
|
||||
) -> TrayConfigFormWidgets:
|
||||
set_language(cfg.get("language", default_config["language"]))
|
||||
no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False))
|
||||
|
||||
_create_header(ctk, frame, theme)
|
||||
appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config)
|
||||
host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config)
|
||||
dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config)
|
||||
cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy(
|
||||
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||
)
|
||||
cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker(
|
||||
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||
)
|
||||
verbose_var, advanced_vars = _create_logging(
|
||||
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||
)
|
||||
check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click)
|
||||
autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value)
|
||||
|
||||
return TrayConfigFormWidgets(
|
||||
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
||||
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
|
||||
advanced_vars=advanced_vars,
|
||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||
cfproxy_var=cfproxy_var,
|
||||
h2_var=h2_var,
|
||||
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
|
||||
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
|
||||
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||
appearance_var=appearance_var,
|
||||
language_var=language_var,
|
||||
)
|
||||
|
||||
|
||||
def _create_header(ctk, frame, theme):
|
||||
header = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
header.pack(fill="x", pady=(0, 2))
|
||||
ctk.CTkLabel(
|
||||
header, text=t("settings.title"),
|
||||
font=(theme.ui_font_family, 17, "bold"),
|
||||
text_color=theme.text_primary, anchor="w",
|
||||
).pack(side="left")
|
||||
ctk.CTkLabel(
|
||||
header, text=f"v{__version__}",
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_secondary, anchor="e",
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
ctk.CTkButton(
|
||||
header, text="Donate ♥", width=90, height=28,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color="#22c55e", hover_color="#16a34a",
|
||||
text_color="#ffffff", border_width=0,
|
||||
command=lambda: (
|
||||
header.winfo_toplevel().iconify(),
|
||||
webbrowser.open(_get_doc_url("Funding")),
|
||||
),
|
||||
).pack(side="right", padx=(0, 6))
|
||||
|
||||
|
||||
def _create_interface(ctk, frame, theme, cfg, default_config):
|
||||
lang_cfg = cfg.get("language", default_config["language"])
|
||||
appearance_var = ctk.StringVar(
|
||||
master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
||||
)
|
||||
|
||||
def _on_appearance_change(choice: str) -> None:
|
||||
cfg_val = _appearance_to_cfg(choice)
|
||||
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||
|
||||
ui_inner = create_config_section(ctk, frame, theme, t("section.interface"))
|
||||
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
|
||||
ui_row.pack(fill="x")
|
||||
|
||||
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
|
||||
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||
|
||||
language_var = ctk.StringVar(master=frame, value=label_from_language(lang_cfg))
|
||||
create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
|
||||
anchor="w", pady=(0, 2)
|
||||
)
|
||||
create_combobox(
|
||||
ctk, lang_col, theme, variable=language_var,
|
||||
values=[label for _, label in language_option_labels()],
|
||||
).pack(fill="x")
|
||||
create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
|
||||
anchor="w", pady=(0, 2)
|
||||
)
|
||||
create_combobox(
|
||||
ctk, theme_col, theme, variable=appearance_var,
|
||||
values=_appearance_options(), command=_on_appearance_change,
|
||||
).pack(fill="x")
|
||||
return appearance_var, language_var
|
||||
|
||||
|
||||
def _create_connection(ctk, frame, theme, cfg, default_config):
|
||||
conn = create_config_section(ctk, frame, theme, t("section.mtproto"))
|
||||
|
||||
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||
host_row.pack(fill="x")
|
||||
|
||||
host_col, host_var = create_labeled_entry(
|
||||
ctk, host_row, theme, t("label.host"),
|
||||
cfg.get("host", default_config["host"]),
|
||||
tip=t("tip.host"), width=160, pack_fill=True,
|
||||
)
|
||||
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
port_col, port_var = create_labeled_entry(
|
||||
ctk, host_row, theme, t("label.port"),
|
||||
cfg.get("port", default_config["port"]),
|
||||
tip=t("tip.port"), width=100,
|
||||
)
|
||||
port_col.pack(side="left")
|
||||
|
||||
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||
secret_row.pack(fill="x")
|
||||
|
||||
secret_col, secret_var = create_labeled_entry(
|
||||
ctk, secret_row, theme, t("label.secret"),
|
||||
cfg.get("secret", default_config["secret"]),
|
||||
tip=t("tip.secret"), width=160, pack_fill=True,
|
||||
)
|
||||
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
|
||||
regen_col.pack(side="left", anchor="s")
|
||||
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
||||
ctk.CTkButton(
|
||||
regen_col, text="↺", width=36, height=36,
|
||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: secret_var.set(os.urandom(16).hex()),
|
||||
).pack()
|
||||
return host_var, port_var, secret_var
|
||||
|
||||
|
||||
def _create_dc(ctk, frame, theme, cfg, default_config):
|
||||
dc_inner = create_config_section(ctk, frame, theme, t("section.dc"))
|
||||
dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
||||
dc_lbl.pack(anchor="w", pady=(0, 4))
|
||||
dc_textbox = ctk.CTkTextbox(
|
||||
dc_inner, width=_INNER_W, height=88,
|
||||
font=(theme.mono_font_family, 12), corner_radius=10,
|
||||
fg_color=theme.bg, border_color=theme.field_border,
|
||||
border_width=1, text_color=theme.text_primary,
|
||||
)
|
||||
dc_textbox.pack(fill="x")
|
||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
||||
return dc_textbox
|
||||
|
||||
|
||||
def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||
cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy"))
|
||||
|
||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_row.pack(fill="x", pady=(0, 4))
|
||||
|
||||
cfproxy_var = ctk.BooleanVar(
|
||||
master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||
)
|
||||
cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
||||
cf_cb.pack(side="left", padx=(0, 16))
|
||||
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
||||
|
||||
cf_test = BackgroundTask(frame.winfo_toplevel())
|
||||
|
||||
def _on_cf_test():
|
||||
secure = not no_secure_var.get()
|
||||
user_domains = (
|
||||
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||
if cf_custom_cb_var.get() else []
|
||||
)
|
||||
if cf_test.running:
|
||||
return
|
||||
_cf_test_widget.configure(text=t("button.test_loading"), state="disabled")
|
||||
|
||||
def finish():
|
||||
_cf_test_widget.configure(text=t("button.test"), state="normal")
|
||||
|
||||
if user_domains:
|
||||
cf_test.start(
|
||||
lambda: run_cfproxy_multi_test(user_domains, secure=secure),
|
||||
lambda per: show_multi_connectivity_results(
|
||||
t("connectivity.cfproxy_title"), per, label_prefix="kws",
|
||||
parent=frame.winfo_toplevel(),
|
||||
),
|
||||
finish,
|
||||
)
|
||||
else:
|
||||
domains = list(balancer.domains)
|
||||
cf_test.start(
|
||||
lambda: run_cfproxy_auto_test(domains, secure=secure),
|
||||
lambda result: show_connectivity_results(
|
||||
t("connectivity.cfproxy_title"), result[1],
|
||||
domain=result[0] or "", auto_mode=True,
|
||||
unavailable_message=t("connectivity.cf_auto_fail"),
|
||||
parent=frame.winfo_toplevel(),
|
||||
),
|
||||
finish,
|
||||
)
|
||||
|
||||
_cf_test_widget = ctk.CTkButton(
|
||||
cf_row, text=t("button.test"), width=56, height=28,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=_on_cf_test,
|
||||
)
|
||||
_cf_test_widget.pack(side="right")
|
||||
|
||||
h2_var = ctk.BooleanVar(master=frame, value=cfg.get("h2", default_config.get("h2", True)))
|
||||
h2_cb = create_checkbox(ctk, cf_inner, theme, t("label.h2_enable"), h2_var)
|
||||
h2_cb.pack(anchor="w", pady=(2, 8))
|
||||
attach_ctk_tooltip(h2_cb, t("tip.h2"))
|
||||
|
||||
def _sync_h2(*_):
|
||||
enabled = cfproxy_var.get() and not no_secure_var.get() and not cfg.get("force_test_dc", False)
|
||||
h2_cb.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
cfproxy_var.trace_add("write", _sync_h2)
|
||||
no_secure_var.trace_add("write", _sync_h2)
|
||||
_sync_h2()
|
||||
|
||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_custom_row.pack(fill="x")
|
||||
|
||||
saved_user_domains = coerce_domain_list(
|
||||
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||
)
|
||||
cf_custom_cb_var = ctk.BooleanVar(
|
||||
master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
|
||||
)
|
||||
cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||
|
||||
ctk.CTkButton(
|
||||
cf_custom_row, text="?", width=28, height=32,
|
||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
|
||||
).pack(side="right")
|
||||
|
||||
cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains))
|
||||
cf_domain_entry = create_entry(
|
||||
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||
height=32, radius=8,
|
||||
)
|
||||
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
|
||||
|
||||
def _sync_domain_entry(*_):
|
||||
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||
cf_domain_entry.configure(state=state)
|
||||
|
||||
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||
_sync_domain_entry()
|
||||
return cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var
|
||||
|
||||
|
||||
def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||
cf_worker_inner = create_config_section(ctk, frame, theme, t("section.cfworker"))
|
||||
|
||||
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||
cf_worker_row.pack(fill="x", pady=(0, 4))
|
||||
cf_worker_lbl = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
|
||||
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
|
||||
|
||||
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||
cf_worker_input.pack(fill="x")
|
||||
|
||||
saved_worker_domains = coerce_domain_list(
|
||||
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||
)
|
||||
cfproxy_worker_enabled_var = ctk.BooleanVar(
|
||||
master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
|
||||
)
|
||||
cf_worker_cb = create_checkbox(
|
||||
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
|
||||
cfproxy_worker_enabled_var,
|
||||
)
|
||||
cf_worker_cb.pack(side="left", padx=(0, 10))
|
||||
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
|
||||
|
||||
cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains))
|
||||
cf_worker_entry = create_entry(
|
||||
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||
height=32, radius=8,
|
||||
)
|
||||
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
|
||||
|
||||
worker_test = BackgroundTask(frame.winfo_toplevel())
|
||||
|
||||
def _sync_cfworker_test_button(*_):
|
||||
btn = _cfworker_test_widget
|
||||
enabled = (
|
||||
not worker_test.running and cfproxy_worker_enabled_var.get()
|
||||
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||
)
|
||||
btn.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def _on_cfworker_test():
|
||||
secure = not no_secure_var.get()
|
||||
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||
if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains:
|
||||
return
|
||||
_cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled")
|
||||
|
||||
def finish():
|
||||
_cfworker_test_widget.configure(text=t("button.test"))
|
||||
_sync_cfworker_test_button()
|
||||
|
||||
worker_test.start(
|
||||
lambda: run_cfworker_multi_test(domains, secure=secure),
|
||||
lambda per: show_multi_connectivity_results(
|
||||
t("connectivity.cfworker_title"), per, label_prefix="DC",
|
||||
parent=frame.winfo_toplevel(),
|
||||
),
|
||||
finish,
|
||||
)
|
||||
|
||||
ctk.CTkButton(
|
||||
cf_worker_input, text="?", width=28, height=32,
|
||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
|
||||
).pack(side="right")
|
||||
|
||||
_cfworker_test_widget = ctk.CTkButton(
|
||||
cf_worker_row, text=t("button.test"), width=56, height=28,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=_on_cfworker_test,
|
||||
)
|
||||
_cfworker_test_widget.pack(side="right")
|
||||
|
||||
def _sync_cfworker_entry(*_):
|
||||
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
|
||||
cf_worker_entry.configure(state=state)
|
||||
_sync_cfworker_test_button()
|
||||
|
||||
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
|
||||
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||
_sync_cfworker_entry()
|
||||
return cfproxy_worker_enabled_var, cfproxy_worker_domain_var
|
||||
|
||||
|
||||
def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||
log_inner = create_config_section(ctk, frame, theme, t("section.logs"))
|
||||
|
||||
verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False))
|
||||
verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
||||
verbose_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
||||
|
||||
no_secure_cb = create_checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
|
||||
no_secure_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
|
||||
|
||||
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
||||
adv_frame.pack(fill="x")
|
||||
|
||||
adv_rows = [
|
||||
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
||||
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
||||
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
||||
]
|
||||
advanced_vars = {}
|
||||
for label_text, key, tip in adv_rows:
|
||||
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
||||
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
|
||||
adv_l = create_label(ctk, col, theme, label_text, size=11)
|
||||
adv_l.pack(anchor="w", pady=(0, 2))
|
||||
advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key])))
|
||||
adv_e = create_entry(
|
||||
ctk, col, theme, width=_INNER_W, height=32, radius=8,
|
||||
var=advanced_vars[key],
|
||||
)
|
||||
adv_e.pack(fill="x")
|
||||
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
|
||||
return verbose_var, advanced_vars
|
||||
|
||||
|
||||
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
|
||||
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
|
||||
st = get_status()
|
||||
check_updates_var = ctk.BooleanVar(
|
||||
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||
)
|
||||
upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||
|
||||
if st.get("error"):
|
||||
upd_status = t("updates.status_error")
|
||||
elif not st.get("checked"):
|
||||
upd_status = t("updates.status_pending")
|
||||
elif st.get("has_update") and st.get("latest"):
|
||||
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||
elif st.get("ahead_of_release") and st.get("latest"):
|
||||
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||
else:
|
||||
upd_status = t("updates.status_latest")
|
||||
|
||||
create_label(ctk, upd_inner, theme, upd_status, size=11,
|
||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
||||
|
||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
if st.get("has_update") and on_update_click is not None:
|
||||
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
|
||||
upd_btn_row.pack(fill="x")
|
||||
upd_btn_row.grid_columnconfigure(0, weight=1)
|
||||
upd_btn_row.grid_columnconfigure(1, weight=1)
|
||||
ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=lambda u=rel_url: webbrowser.open(u),
|
||||
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
|
||||
ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.update"), height=32,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
command=on_update_click,
|
||||
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
|
||||
else:
|
||||
ctk.CTkButton(
|
||||
upd_inner, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=lambda u=rel_url: webbrowser.open(u),
|
||||
).pack(anchor="w")
|
||||
return check_updates_var
|
||||
|
||||
|
||||
def _create_autostart(ctk, frame, theme, show_autostart, autostart_value):
|
||||
autostart_var = None
|
||||
if show_autostart:
|
||||
sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
||||
autostart_var = ctk.BooleanVar(master=frame, value=autostart_value)
|
||||
as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
||||
as_cb.pack(anchor="w", pady=(0, 4))
|
||||
as_hint = create_label(
|
||||
ctk, sys_inner, theme,
|
||||
t("label.autostart_hint"),
|
||||
size=11, justify="left", wraplength=_INNER_W,
|
||||
)
|
||||
as_hint.pack(anchor="w")
|
||||
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
||||
return autostart_var
|
||||
|
||||
|
||||
def validate_config_form(
|
||||
widgets: TrayConfigFormWidgets,
|
||||
default_config: dict,
|
||||
*,
|
||||
include_autostart: bool,
|
||||
) -> Union[dict, str]:
|
||||
values = {
|
||||
"host": widgets.host_var.get(),
|
||||
"port": widgets.port_var.get(),
|
||||
"secret": widgets.secret_var.get(),
|
||||
"dc_ip": widgets.dc_textbox.get("1.0", "end"),
|
||||
"verbose": bool(widgets.verbose_var.get()),
|
||||
}
|
||||
for key, var in widgets.advanced_vars.items():
|
||||
values[key] = var.get()
|
||||
for key in (
|
||||
"check_updates", "cfproxy", "h2", "cfproxy_user_domain_enabled",
|
||||
"cfproxy_worker_enabled", "no_secure",
|
||||
):
|
||||
var = getattr(widgets, f"{key}_var")
|
||||
if var is not None:
|
||||
values[key] = bool(var.get())
|
||||
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
|
||||
var = getattr(widgets, f"{key}_var")
|
||||
if var is not None:
|
||||
values[key] = var.get()
|
||||
if include_autostart:
|
||||
values["autostart"] = (
|
||||
bool(widgets.autostart_var.get())
|
||||
if widgets.autostart_var is not None else False
|
||||
)
|
||||
if widgets.appearance_var is not None:
|
||||
values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
|
||||
if widgets.language_var is not None:
|
||||
values["language"] = language_from_label(widgets.language_var.get()).value
|
||||
return validate_settings(values, default_config)
|
||||
|
||||
|
||||
@@ -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,16 +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
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
from copy import deepcopy
|
||||
|
||||
import psutil
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from proxy.utils import DomainCensorFilter
|
||||
from proxy.tg_ws_proxy import _run
|
||||
from utils.default_config import default_tray_config
|
||||
from utils.diagnostics import diagnose_listen_error
|
||||
@@ -210,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
|
||||
|
||||
@@ -237,12 +239,14 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
||||
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
fh.addFilter(DomainCensorFilter())
|
||||
root.addHandler(fh)
|
||||
|
||||
if not IS_FROZEN:
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(level)
|
||||
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
|
||||
ch.addFilter(DomainCensorFilter())
|
||||
root.addHandler(ch)
|
||||
|
||||
|
||||
@@ -354,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"])
|
||||
)
|
||||
@@ -371,6 +376,7 @@ def apply_proxy_config(cfg: dict) -> bool:
|
||||
else []
|
||||
)
|
||||
pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
|
||||
pc.disable_secure = cfg.get("no_secure", DEFAULT_CONFIG["no_secure"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+75
-79
@@ -34,7 +34,7 @@ try:
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
from proxy import get_link_host
|
||||
from proxy import __version__, get_link_host
|
||||
|
||||
from utils.win32_theme import (
|
||||
is_windows_dark_theme,
|
||||
@@ -47,21 +47,25 @@ from utils.tray_common import (
|
||||
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 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 = {}
|
||||
_exiting = False
|
||||
_win_mutex_handle = None
|
||||
_update_flow_lock = threading.Lock()
|
||||
|
||||
_ERROR_ALREADY_EXISTS = 183
|
||||
|
||||
@@ -146,6 +150,7 @@ def update_ctk_form(
|
||||
width=310 if IS_FROZEN else 210,
|
||||
height=130 if IS_FROZEN else 100,
|
||||
theme=theme,
|
||||
topmost=False,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
|
||||
@@ -203,7 +208,7 @@ def update_ctk_form(
|
||||
btns.append(btn_upd)
|
||||
btn_pg = ctk.CTkButton(
|
||||
row, text=t("button.page"), width=88, height=34,
|
||||
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
|
||||
font=(theme.ui_font_family, 13), command=lambda: webbrowser.open(release_url or RELEASES_PAGE_URL),
|
||||
)
|
||||
btn_pg.pack(side="left", padx=(0, 6))
|
||||
btns.append(btn_pg)
|
||||
@@ -317,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _trigger_update_flow(icon=None, item=None) -> None:
|
||||
"""Show the update dialog for an update already known to be available.
|
||||
|
||||
Reused by the tray "Update" item and the settings dialog's "Update"
|
||||
button, so an update can still be started after the initial startup
|
||||
prompt was skipped/closed.
|
||||
"""
|
||||
if not _update_flow_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
def _show() -> None:
|
||||
try:
|
||||
if _exiting:
|
||||
return
|
||||
st = get_status()
|
||||
if not st.get("has_update"):
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||
|
||||
choice = update_ctk_form(
|
||||
t("update.available", version=ver),
|
||||
download_url=asset[0] if asset else None,
|
||||
release_url=url,
|
||||
)
|
||||
if choice == "open":
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
log.warning("Update flow failed: %s", repr(exc))
|
||||
finally:
|
||||
_update_flow_lock.release()
|
||||
|
||||
threading.Thread(target=_show, daemon=True, name="manual-update").start()
|
||||
|
||||
|
||||
def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
||||
if not cfg.get("check_updates", True):
|
||||
return
|
||||
@@ -326,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
||||
if is_exiting():
|
||||
return
|
||||
try:
|
||||
from proxy import __version__
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
|
||||
|
||||
run_check(__version__)
|
||||
st = get_status()
|
||||
if not st.get("has_update") or is_exiting():
|
||||
if _tray_icon is not None:
|
||||
_tray_icon.menu = _build_menu()
|
||||
if is_exiting():
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||
choice = update_ctk_form(
|
||||
t("update.available", version=ver),
|
||||
download_url=asset[0] if asset else None,
|
||||
release_url=url,
|
||||
)
|
||||
if choice == "open":
|
||||
webbrowser.open(url)
|
||||
_trigger_update_flow()
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", repr(exc))
|
||||
|
||||
@@ -470,8 +500,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()
|
||||
@@ -481,7 +509,7 @@ def _edit_config_dialog() -> None:
|
||||
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
@@ -491,71 +519,28 @@ 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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -579,7 +564,7 @@ def _show_first_run() -> None:
|
||||
w, h = FIRST_RUN_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
|
||||
def on_done(open_tg: bool) -> None:
|
||||
@@ -602,7 +587,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,
|
||||
@@ -611,7 +596,18 @@ 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.get("has_update"):
|
||||
items[-2:-2] = [
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(
|
||||
t("tray.update", current=__version__, new=st.get("latest") or "?"),
|
||||
_trigger_update_flow,
|
||||
)
|
||||
]
|
||||
return pystray.Menu(*items)
|
||||
|
||||
|
||||
# entry point
|
||||
|
||||
Reference in new issue
Block a user