mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-31 00:06:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc2c4c03f6 | ||
|
|
73aa394d9a | ||
|
|
a70fc8c5c1 | ||
|
|
44c5ee2a57 | ||
|
|
7fb01a7d05 | ||
|
|
08abb69df5 | ||
|
|
307a384ab7 | ||
|
|
241d76d7b9 | ||
|
|
7460091c19 | ||
|
|
9deed3707a | ||
|
|
c52714f264 | ||
|
|
03be9baad1 | ||
|
|
c317406b52 | ||
|
|
bf07b42f88 | ||
|
|
a6177fec87 | ||
|
|
5f8820d41f | ||
|
|
3cda0f5446 | ||
|
|
59c3a6e3b3 | ||
|
|
9109570d32 | ||
|
|
00d72a4c7f | ||
|
|
a0a92f5e7e | ||
|
|
a232472cbe | ||
|
|
3fa281625f | ||
|
|
3c54c54d36 | ||
|
|
03421197c8 | ||
|
|
18175fb4fe | ||
|
|
f742a8957d | ||
|
|
3686c36462 | ||
|
|
4bdd69ade9 | ||
|
|
50c980e9ea | ||
|
|
48954ff9ab | ||
|
|
fe1580cd83 | ||
|
|
ad7f3cae9e | ||
|
|
caa949bee0 | ||
|
|
3d59c813aa |
No files matched your search
@@ -12,6 +12,11 @@ on:
|
|||||||
description: "Release version tag (e.g. v1.0.0)"
|
description: "Release version tag (e.g. v1.0.0)"
|
||||||
required: false
|
required: false
|
||||||
default: "v1.0.0"
|
default: "v1.0.0"
|
||||||
|
sign_windows:
|
||||||
|
description: 'Sign?'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
build_windows_x64:
|
build_windows_x64:
|
||||||
description: 'Build Windows x64?'
|
description: 'Build Windows x64?'
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -39,8 +44,15 @@ on:
|
|||||||
default: true
|
default: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
SIGNPATH_ORGANIZATION_ID: d0164909-65ed-4ca8-aa9f-ec148a9f7950
|
||||||
|
SIGNPATH_PROJECT_SLUG: tg-ws-proxy
|
||||||
|
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: initial
|
||||||
|
SIGNPATH_SIGNING_POLICY: ${{ github.event.inputs.make_release == 'true' && 'release-signing' || 'test-signing' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-x64:
|
build-windows-x64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -96,11 +108,36 @@ jobs:
|
|||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload unsigned artifact
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
id: upload-unsigned
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: unsigned-windows-x64
|
||||||
|
path: dist/TgWsProxy_windows.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Sign EXE with SignPath
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
uses: signpath/github-action-submit-signing-request@v3
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
|
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||||
|
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy-windows-x64
|
name: TgWsProxy-windows-x64
|
||||||
path: dist/TgWsProxy_windows.exe
|
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows-arm64:
|
build-windows-arm64:
|
||||||
runs-on: windows-11-arm
|
runs-on: windows-11-arm
|
||||||
@@ -173,11 +210,36 @@ jobs:
|
|||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload unsigned artifact
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
id: upload-unsigned
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: unsigned-windows-arm64
|
||||||
|
path: dist/TgWsProxy_windows_arm64.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Sign EXE with SignPath
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
uses: signpath/github-action-submit-signing-request@v3
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
|
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||||
|
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy-windows-arm64
|
name: TgWsProxy-windows-arm64
|
||||||
path: dist/TgWsProxy_windows_arm64.exe
|
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_arm64.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-win7:
|
build-win7:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -227,11 +289,36 @@ jobs:
|
|||||||
- name: Rename artifact
|
- name: Rename artifact
|
||||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload unsigned artifact
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
id: upload-unsigned
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: unsigned-win7-${{ matrix.suffix }}
|
||||||
|
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Sign EXE with SignPath
|
||||||
|
if: ${{ inputs.sign_windows }}
|
||||||
|
uses: signpath/github-action-submit-signing-request@v3
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
|
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||||
|
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy-win7-${{ matrix.suffix }}
|
name: TgWsProxy-win7-${{ matrix.suffix }}
|
||||||
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||||
|
archive: true
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -573,7 +660,7 @@ jobs:
|
|||||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
---
|
---
|
||||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/Funding.md)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Не можете скачать?
|
> Не можете скачать?
|
||||||
|
|||||||
+4
-3
@@ -15,7 +15,7 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi
|
RUN "$VIRTUAL_ENV/bin/pip" install cryptography==46.0.5 certifi "httpx[http2]==0.28.1"
|
||||||
|
|
||||||
FROM python:3.12-slim AS runtime
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
@@ -26,7 +26,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
TG_WS_PROXY_PORT=1443 \
|
TG_WS_PROXY_PORT=1443 \
|
||||||
TG_WS_PROXY_SECRET="" \
|
TG_WS_PROXY_SECRET="" \
|
||||||
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
|
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
|
||||||
TG_WS_PROXY_CF_WORKER=""
|
TG_WS_PROXY_CF_WORKER="" \
|
||||||
|
TG_WS_PROXY_NO_SECURE=False
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||||
@@ -44,5 +45,5 @@ USER app
|
|||||||
|
|
||||||
EXPOSE 1443/tcp
|
EXPOSE 1443/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; if [ \"${TG_WS_PROXY_NO_SECURE}\" = \"True\" ]; then args=\"$args --no-secure \"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||||
CMD []
|
CMD []
|
||||||
@@ -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,16 +49,18 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
|||||||
| `--secret` | `random` | 32-character hex key for client authorization |
|
| `--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) |
|
| `--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-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-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. |
|
| `--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 |
|
| `--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 |
|
| `--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`) |
|
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
|
||||||
|
| `--force-test-dc` | `false` | Route all traffic to Telegram test DCs [Learn more](./TestDc.md) |
|
||||||
| `--buf-kb` | `256` | Buffer size in KB |
|
| `--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-file` | disabled | Path to file for saving logs |
|
||||||
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
|
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
|
||||||
| `--log-backups` | `0` | Number of log backups after overwrite |
|
| `--log-backups` | `1` | Number of rotated log files to keep (minimum 1) |
|
||||||
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
|
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|||||||
@@ -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.
|
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
|
## Credits / Acknowledgments
|
||||||
|
|
||||||
- Original Idea: https://github.com/Nekogram/WSProxy
|
- Original Idea: https://github.com/Nekogram/WSProxy
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
|||||||
All settings are configured using environment variables when running the container:
|
All settings are configured using environment variables when running the container:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ----------------------- | -------------------------------- | --------------------------------- |
|
| ----------------------- | -------------------------------------------------- | ------------------------------------- |
|
||||||
| `TG_WS_PROXY_HOST` | Address for incoming connections | `0.0.0.0` |
|
| `TG_WS_PROXY_HOST` | Address for incoming connections | `0.0.0.0` |
|
||||||
| `TG_WS_PROXY_PORT` | Port inside the container | `1443` |
|
| `TG_WS_PROXY_PORT` | Port inside the container | `1443` |
|
||||||
| `TG_WS_PROXY_SECRET` | Secret key | `random` |
|
| `TG_WS_PROXY_SECRET` | Secret key | `random` |
|
||||||
| `TG_WS_PROXY_DC_IPS` | DC number:IP pairs separated by space | `2:149.154.167.220 4:149.154.167.220` |
|
| `TG_WS_PROXY_DC_IPS` | DC number:IP pairs separated by space | `2:149.154.167.220 4:149.154.167.220` |
|
||||||
| `TG_WS_PROXY_CF_WORKER` | Cloudflare Worker domain | `None` |
|
| `TG_WS_PROXY_CF_WORKER` | Cloudflare Worker domain | `None` |
|
||||||
|
| `TG_WS_PROXY_NO_SECURE` | Use 80 port for CF-proxy and CF-worker connections | `False` |
|
||||||
|
|
||||||
Example with manually specified secret:
|
Example with manually specified secret:
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
|
|||||||
|
|
||||||
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
|
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
When update checks are enabled in settings, the app offers to update itself on startup and installs the new version:
|
||||||
|
|
||||||
|
- installed from `.deb`/`.rpm` — the new package is downloaded and installed by the package manager (via `pkexec`, password confirmation required);
|
||||||
|
- standalone binary — replaced in place if its directory is writable, otherwise also via `pkexec`.
|
||||||
|
|
||||||
|
The downloaded file is checked against the response length and the SHA-256 digest from the release; a corrupted download is never installed. The app restarts automatically after the update.
|
||||||
|
|
||||||
|
For AUR packages, `pip` installs, and non-x86_64 architectures the previous behaviour applies — the release page is opened.
|
||||||
|
|
||||||
## Configuring Telegram Desktop
|
## Configuring Telegram Desktop
|
||||||
|
|
||||||
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
|
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ The tray application stores data in:
|
|||||||
"verbose": false,
|
"verbose": false,
|
||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"log_max_mb": 5.0,
|
"log_max_mb": 5,
|
||||||
"check_updates": true,
|
"check_updates": true,
|
||||||
"cfproxy": true,
|
"cfproxy": true,
|
||||||
"cfproxy_user_domain": "",
|
"h2": true,
|
||||||
"cfproxy_worker_domain": "",
|
"cfproxy_user_domain_enabled": false,
|
||||||
|
"cfproxy_user_domain": [],
|
||||||
|
"cfproxy_worker_enabled": false,
|
||||||
|
"cfproxy_worker_domain": [],
|
||||||
"force_test_dc": false,
|
"force_test_dc": false,
|
||||||
"appearance": "auto"
|
"no_secure": false,
|
||||||
|
"appearance": "auto",
|
||||||
|
"language": "en"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
> [!TIP]
|
|
||||||
>
|
|
||||||
> ### 🎉 Поддержать меня
|
|
||||||
>
|
|
||||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
|
||||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
|
||||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
|
||||||
|
|
||||||
Проект полностью бесплатен для всех.
|
|
||||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
|
||||||
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
|
||||||
@@ -49,16 +49,18 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
|||||||
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
||||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
||||||
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
||||||
|
| `--no-h2` | `false` | Отключить мультиплексирование медиа через HTTP/2 в CF-прокси |
|
||||||
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
|
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
|
||||||
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||||
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||||
|
| `--force-test-dc` | `false` | Направлять весь трафик на тестовые DC Telegram [Подробнее](./TestDc.md) |
|
||||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
|
| `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
|
||||||
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
||||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
||||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
| `--log-backups` | `1` | Сколько старых файлов логов хранить при ротации (минимум 1) |
|
||||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||||
|
|
||||||
**Примеры:**
|
**Примеры:**
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ Cloudflare имеет лимиты на одновременное количе
|
|||||||
|
|
||||||
5. В настройках `TgWsProxy` замените домен на свой.
|
5. В настройках `TgWsProxy` замените домен на свой.
|
||||||
|
|
||||||
|
## Мультиплексирование медиа через HTTP/2
|
||||||
|
|
||||||
|
Мультиплексирование включено по умолчанию. Медиа-соединения используют `/api` вместо `/apiws` через одно общее TCP/TLS-соединение до Cloudflare для каждого DC и домена. Основные соединения и тестовые DC продолжают использовать WS.
|
||||||
|
Чтобы отключить мультиплексирование, добавьте `--no-h2` или снимите галочку
|
||||||
|
**Мультиплексирование медиа (HTTP/2)** в настройках **Cloudflare Proxy**.
|
||||||
|
|
||||||
## Благодарности
|
## Благодарности
|
||||||
|
|
||||||
- Идея: https://github.com/Nekogram/WSProxy
|
- Идея: https://github.com/Nekogram/WSProxy
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
|||||||
Все настройки задаются переменными окружения при запуске контейнера:
|
Все настройки задаются переменными окружения при запуске контейнера:
|
||||||
|
|
||||||
| Переменная | Описание | По умолчанию |
|
| Переменная | Описание | По умолчанию |
|
||||||
| ----------------------- | --------------------------------- | ------------------------------------- |
|
| ----------------------- | ------------------------------------------------------------- | ------------------------------------- |
|
||||||
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
|
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
|
||||||
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
|
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
|
||||||
| `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
|
| `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
|
||||||
| `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
|
| `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
|
||||||
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
||||||
|
| `TG_WS_PROXY_NO_SECURE` | `Использовать 80 порт для подключения к CF-proxy и CF-Worker` | `False` |
|
||||||
|
|
||||||
Пример с ручным указанием секрета:
|
Пример с ручным указанием секрета:
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
|
|||||||
|
|
||||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию:
|
||||||
|
|
||||||
|
- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля);
|
||||||
|
- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`.
|
||||||
|
|
||||||
|
Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически.
|
||||||
|
|
||||||
|
Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза.
|
||||||
|
|
||||||
## Настройка Telegram Desktop
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ Tray-приложение хранит данные в:
|
|||||||
"verbose": false,
|
"verbose": false,
|
||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"log_max_mb": 5.0,
|
"log_max_mb": 5,
|
||||||
"check_updates": true,
|
"check_updates": true,
|
||||||
"cfproxy": true,
|
"cfproxy": true,
|
||||||
"cfproxy_user_domain": "",
|
"h2": true,
|
||||||
"cfproxy_worker_domain": "",
|
"cfproxy_user_domain_enabled": false,
|
||||||
|
"cfproxy_user_domain": [],
|
||||||
|
"cfproxy_worker_enabled": false,
|
||||||
|
"cfproxy_worker_domain": [],
|
||||||
"force_test_dc": false,
|
"force_test_dc": false,
|
||||||
"appearance": "auto"
|
"no_secure": false,
|
||||||
|
"appearance": "auto",
|
||||||
|
"language": "ru"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
@@ -12,29 +13,31 @@ import pyperclip
|
|||||||
import pystray
|
import pystray
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
from proxy import get_link_host
|
from proxy import __version__, get_link_host
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
acquire_lock, bootstrap, check_ipv6_warning, check_update_async,
|
||||||
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
ctk_run_dialog, ensure_ctk_thread, ensure_dirs, load_config, load_icon,
|
||||||
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
|
log, quit_ctk, release_lock, restart_proxy,
|
||||||
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||||
)
|
)
|
||||||
from ui.ctk_tray_ui import (
|
from ui.ctk_tray_ui import (
|
||||||
install_tray_config_buttons, install_tray_config_form,
|
install_tray_config_buttons, install_tray_config_form,
|
||||||
populate_first_run_window, tray_settings_scroll_and_footer,
|
populate_first_run_window, tray_settings_scroll_and_footer,
|
||||||
validate_config_form,
|
|
||||||
)
|
)
|
||||||
from ui.ctk_theme import (
|
from ui.ctk_theme import (
|
||||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||||
)
|
)
|
||||||
from ui.i18n import set_language, t
|
from ui.i18n import t
|
||||||
|
from ui.settings_dialog import SettingsDialog
|
||||||
|
from utils.update_check import get_status, relaunch_env
|
||||||
|
|
||||||
_tray_icon: Optional[object] = None
|
_tray_icon: Optional[object] = None
|
||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
_exiting = False
|
_exiting = False
|
||||||
|
_update_flow_lock = threading.Lock()
|
||||||
|
|
||||||
# dialogs (tkinter messagebox)
|
# dialogs (tkinter messagebox)
|
||||||
|
|
||||||
@@ -158,68 +161,25 @@ def _edit_config_dialog() -> None:
|
|||||||
if _tray_icon is not None:
|
if _tray_icon is not None:
|
||||||
_tray_icon.menu = _build_menu()
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
|
|
||||||
|
|
||||||
widgets = install_tray_config_form(
|
widgets = install_tray_config_form(
|
||||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||||
show_autostart=False,
|
show_autostart=False,
|
||||||
on_language_change=_refresh_tray_menu,
|
on_update_click=_trigger_update_flow,
|
||||||
)
|
)
|
||||||
|
|
||||||
_original_appearance = ctk.get_appearance_mode()
|
|
||||||
|
|
||||||
def _restore_ui_locale() -> None:
|
|
||||||
set_language(_original_language)
|
|
||||||
_refresh_tray_menu()
|
|
||||||
|
|
||||||
def _finish() -> None:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
def _cancel() -> None:
|
dialog = SettingsDialog(
|
||||||
ctk.set_appearance_mode(_original_appearance)
|
ctk=ctk, root=root, widgets=widgets, config=_config,
|
||||||
_restore_ui_locale()
|
defaults=DEFAULT_CONFIG, persist=save_config,
|
||||||
_finish()
|
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)
|
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
|
||||||
|
|
||||||
ctk_run_dialog(_build)
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
@@ -259,6 +219,78 @@ def _show_first_run() -> None:
|
|||||||
ctk_run_dialog(_build)
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
|
|
||||||
|
# update
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_after_update() -> None:
|
||||||
|
from utils.linux_update import exe_path
|
||||||
|
|
||||||
|
stop_proxy()
|
||||||
|
release_lock()
|
||||||
|
exe = str(exe_path())
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
[exe, *sys.argv[1:]], env=relaunch_env(),
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("Failed to launch updated binary: %s", exc)
|
||||||
|
if acquire_lock():
|
||||||
|
start_proxy(_config, _show_error)
|
||||||
|
_show_error(t("update.error", msg=exc))
|
||||||
|
return
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_update_flow(icon=None, item=None) -> None:
|
||||||
|
if not _update_flow_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _work() -> None:
|
||||||
|
from utils.linux_update import asset_name, detect_install_kind
|
||||||
|
from utils.update_check import find_asset, RELEASES_PAGE_URL
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = get_status()
|
||||||
|
if _exiting or not st["has_update"]:
|
||||||
|
return
|
||||||
|
if _tray_icon is not None:
|
||||||
|
_tray_icon.menu = _build_menu()
|
||||||
|
kind = detect_install_kind() if IS_FROZEN else None
|
||||||
|
asset = find_asset(asset_name(kind)) if kind else None
|
||||||
|
page_url = st["html_url"] or RELEASES_PAGE_URL
|
||||||
|
if not asset:
|
||||||
|
if _ask_yes_no(t("update.ask_open", version=st["latest"]), t("app.update_title")):
|
||||||
|
webbrowser.open(page_url)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")):
|
||||||
|
_install_update(kind, asset, page_url)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Update flow failed")
|
||||||
|
finally:
|
||||||
|
_update_flow_lock.release()
|
||||||
|
|
||||||
|
threading.Thread(target=_work, daemon=True, name="manual-update").start()
|
||||||
|
|
||||||
|
|
||||||
|
def _install_update(kind: str, asset: dict, page_url: str) -> None:
|
||||||
|
from utils.linux_update import apply_update
|
||||||
|
|
||||||
|
err = apply_update(
|
||||||
|
kind, asset["url"], digest=asset.get("digest", ""), log=log,
|
||||||
|
)
|
||||||
|
if err is None:
|
||||||
|
log.info("Update installed, restarting")
|
||||||
|
_restart_after_update()
|
||||||
|
return
|
||||||
|
|
||||||
|
log.error("Update failed: %s", err)
|
||||||
|
if _ask_yes_no(t("update.install_fail", error=err), t("app.update_title")):
|
||||||
|
webbrowser.open(page_url)
|
||||||
|
|
||||||
|
|
||||||
# tray menu
|
# tray menu
|
||||||
|
|
||||||
|
|
||||||
@@ -266,7 +298,7 @@ def _build_menu():
|
|||||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||||
link_host = get_link_host(host)
|
link_host = get_link_host(host)
|
||||||
return pystray.Menu(
|
items = [
|
||||||
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||||
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
@@ -275,7 +307,13 @@ def _build_menu():
|
|||||||
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
pystray.MenuItem(t("tray.exit"), _on_exit),
|
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||||
)
|
]
|
||||||
|
st = get_status()
|
||||||
|
if st["has_update"]:
|
||||||
|
items.insert(-2, pystray.MenuItem(
|
||||||
|
t("tray.update", current=__version__, new=st["latest"]), _trigger_update_flow,
|
||||||
|
))
|
||||||
|
return pystray.Menu(*items)
|
||||||
|
|
||||||
|
|
||||||
# entry point
|
# entry point
|
||||||
@@ -298,7 +336,7 @@ def run_tray() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
start_proxy(_config, _show_error)
|
start_proxy(_config, _show_error)
|
||||||
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
|
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
|
||||||
_show_first_run()
|
_show_first_run()
|
||||||
check_ipv6_warning(_show_info)
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ from ui.ctk_tray_ui import (
|
|||||||
install_tray_config_form,
|
install_tray_config_form,
|
||||||
populate_first_run_window,
|
populate_first_run_window,
|
||||||
tray_settings_scroll_and_footer,
|
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 (
|
from utils.tray_common import (
|
||||||
APP_DIR,
|
APP_DIR,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
@@ -362,7 +362,6 @@ def _edit_config_dialog() -> None:
|
|||||||
pady=frame_pad_y,
|
pady=frame_pad_y,
|
||||||
)
|
)
|
||||||
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
|
||||||
original_language = _config.get("language", DEFAULT_CONFIG["language"])
|
|
||||||
log.info("Building settings form")
|
log.info("Building settings form")
|
||||||
widgets = install_tray_config_form(
|
widgets = install_tray_config_form(
|
||||||
ctk,
|
ctk,
|
||||||
@@ -371,14 +370,8 @@ def _edit_config_dialog() -> None:
|
|||||||
cfg,
|
cfg,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
show_autostart=False,
|
show_autostart=False,
|
||||||
on_language_change=_refresh_tray_menu,
|
|
||||||
)
|
)
|
||||||
log.info("Settings form built")
|
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:
|
def finish() -> None:
|
||||||
global _settings_window
|
global _settings_window
|
||||||
@@ -386,70 +379,20 @@ def _edit_config_dialog() -> None:
|
|||||||
_settings_window = None
|
_settings_window = None
|
||||||
_hide_ctk_root()
|
_hide_ctk_root()
|
||||||
|
|
||||||
def cancel() -> None:
|
dialog = SettingsDialog(
|
||||||
ctk.set_appearance_mode(original_appearance)
|
ctk=ctk, root=root, widgets=widgets, config=_config,
|
||||||
restore_ui_locale()
|
defaults=DEFAULT_CONFIG, persist=save_config,
|
||||||
finish()
|
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:
|
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||||
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)
|
|
||||||
install_tray_config_buttons(
|
install_tray_config_buttons(
|
||||||
ctk,
|
ctk,
|
||||||
footer,
|
footer,
|
||||||
theme,
|
theme,
|
||||||
on_save=save,
|
on_save=dialog.save,
|
||||||
on_cancel=cancel,
|
on_cancel=dialog.cancel,
|
||||||
)
|
)
|
||||||
_activate_app()
|
_activate_app()
|
||||||
log.info("Settings window ready")
|
log.info("Settings window ready")
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
import certifi
|
||||||
from PyInstaller.utils.hooks import collect_data_files
|
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
# customtkinter ships JSON themes + assets that must be bundled
|
# customtkinter ships JSON themes + assets that must be bundled
|
||||||
import customtkinter
|
import customtkinter
|
||||||
ctk_path = os.path.dirname(customtkinter.__file__)
|
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||||
certifi_datas = collect_data_files('certifi')
|
certifi_cafile = certifi.where()
|
||||||
|
|
||||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||||
|
|
||||||
@@ -25,8 +24,10 @@ a = Analysis(
|
|||||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=appindicator_binaries,
|
binaries=appindicator_binaries,
|
||||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
|
'httpx',
|
||||||
|
'h2',
|
||||||
'pystray._appindicator',
|
'pystray._appindicator',
|
||||||
'PIL._tkinter_finder',
|
'PIL._tkinter_finder',
|
||||||
'customtkinter',
|
'customtkinter',
|
||||||
@@ -61,6 +62,8 @@ a = Analysis(
|
|||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
a.datas += [('certifi/cacert.pem', certifi_cafile, 'DATA')]
|
||||||
|
|
||||||
_required_libraries = {
|
_required_libraries = {
|
||||||
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
|
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
|
||||||
'libgtk-3.so.0', 'libappindicator3.so.1',
|
'libgtk-3.so.0', 'libappindicator3.so.1',
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ a = Analysis(
|
|||||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
|
'httpx',
|
||||||
|
'h2',
|
||||||
'tkinter',
|
'tkinter',
|
||||||
'customtkinter',
|
'customtkinter',
|
||||||
'pystray._darwin',
|
'pystray._darwin',
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||||
VSVersionInfo(
|
VSVersionInfo(
|
||||||
ffi=FixedFileInfo(
|
ffi=FixedFileInfo(
|
||||||
filevers=(1, 10, 4, 0),
|
filevers=(1, 11, 3, 0),
|
||||||
prodvers=(1, 10, 4, 0),
|
prodvers=(1, 11, 3, 0),
|
||||||
mask=0x3f,
|
mask=0x3f,
|
||||||
flags=0x0,
|
flags=0x0,
|
||||||
OS=0x40004,
|
OS=0x40004,
|
||||||
@@ -21,12 +21,12 @@ VSVersionInfo(
|
|||||||
[
|
[
|
||||||
StringStruct(u'CompanyName', u'Flowseal'),
|
StringStruct(u'CompanyName', u'Flowseal'),
|
||||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||||
StringStruct(u'FileVersion', u'1.10.4.0'),
|
StringStruct(u'FileVersion', u'1.11.3.0'),
|
||||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||||
StringStruct(u'ProductVersion', u'1.10.4.0'),
|
StringStruct(u'ProductVersion', u'1.11.3.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ a = Analysis(
|
|||||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
|
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
|
'httpx',
|
||||||
|
'h2',
|
||||||
'pystray._win32',
|
'pystray._win32',
|
||||||
'PIL._tkinter_finder',
|
'PIL._tkinter_finder',
|
||||||
'customtkinter',
|
'customtkinter',
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
from .utils import get_link_host, build_github_opener
|
from .utils import get_link_host, build_github_opener
|
||||||
|
|
||||||
__version__ = "1.10.4"
|
__version__ = "1.11.3"
|
||||||
|
|
||||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||||
+89
-27
@@ -1,8 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from .utils import *
|
from .utils import *
|
||||||
@@ -11,13 +12,24 @@ from .balancer import balancer
|
|||||||
from .config import proxy_config
|
from .config import proxy_config
|
||||||
from .raw_websocket import RawWebSocket
|
from .raw_websocket import RawWebSocket
|
||||||
from .pool import cf_worker_pool
|
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')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
_st_I_le = struct.Struct('<I')
|
_st_I_le = struct.Struct('<I')
|
||||||
|
|
||||||
ZERO_64 = b'\x00' * 64
|
TCP_BACKOFF_INITIAL = 30.0
|
||||||
|
TCP_BACKOFF_MAX = 3600.0
|
||||||
|
_tcp_failures: Dict[Tuple[str, int], int] = {}
|
||||||
|
_tcp_retry_after: Dict[Tuple[str, int], float] = {}
|
||||||
|
_tcp_connecting: Set[Tuple[str, int]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_tcp_backoff() -> None:
|
||||||
|
_tcp_failures.clear()
|
||||||
|
_tcp_retry_after.clear()
|
||||||
|
_tcp_connecting.clear()
|
||||||
|
|
||||||
|
|
||||||
class CryptoCtx:
|
class CryptoCtx:
|
||||||
@@ -35,43 +47,38 @@ class MsgSplitter:
|
|||||||
Splits TCP stream data into individual MTProto transport packets
|
Splits TCP stream data into individual MTProto transport packets
|
||||||
so each can be sent as a separate WS frame.
|
so each can be sent as a separate WS frame.
|
||||||
"""
|
"""
|
||||||
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
__slots__ = ('_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||||
|
|
||||||
def __init__(self, relay_init: bytes, proto_int: int):
|
def __init__(self, proto_int: int):
|
||||||
cipher = Cipher(algorithms.AES(relay_init[8:40]),
|
|
||||||
modes.CTR(relay_init[40:56]))
|
|
||||||
self._dec = cipher.encryptor()
|
|
||||||
self._dec.update(ZERO_64)
|
|
||||||
self._proto = proto_int
|
self._proto = proto_int
|
||||||
self._cipher_buf = bytearray()
|
self._cipher_buf = bytearray()
|
||||||
self._plain_buf = bytearray()
|
self._plain_buf = bytearray()
|
||||||
self._disabled = False
|
self._disabled = False
|
||||||
|
|
||||||
def split(self, chunk: bytes) -> List[bytes]:
|
def split(self, chunk: bytes, plain: bytes) -> List[bytes]:
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return []
|
return []
|
||||||
if self._disabled:
|
if self._disabled:
|
||||||
return [chunk]
|
return [chunk]
|
||||||
|
|
||||||
self._cipher_buf.extend(chunk)
|
self._cipher_buf.extend(chunk)
|
||||||
self._plain_buf.extend(self._dec.update(chunk))
|
self._plain_buf.extend(plain)
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
offset = 0
|
offset = 0
|
||||||
buf_len = len(self._cipher_buf)
|
buf_len = len(self._cipher_buf)
|
||||||
# Walk the buffer with an offset instead of deleting each packet from
|
|
||||||
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
|
||||||
# so a chunk holding many small packets degrades to O(N^2); a single
|
|
||||||
# trailing del keeps splitting O(N).
|
|
||||||
while offset < buf_len:
|
while offset < buf_len:
|
||||||
packet_len = self._next_packet_len(offset, buf_len - offset)
|
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||||
if packet_len is None:
|
if packet_len is None:
|
||||||
break
|
break
|
||||||
if packet_len <= 0:
|
if packet_len <= 0:
|
||||||
parts.append(bytes(self._cipher_buf[offset:]))
|
parts.append(bytes(self._cipher_buf) if offset == 0 else bytes(self._cipher_buf[offset:]))
|
||||||
offset = buf_len
|
offset = buf_len
|
||||||
self._disabled = True
|
self._disabled = True
|
||||||
break
|
break
|
||||||
|
if offset == 0 and packet_len == buf_len:
|
||||||
|
parts.append(bytes(self._cipher_buf))
|
||||||
|
else:
|
||||||
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||||
offset += packet_len
|
offset += packet_len
|
||||||
|
|
||||||
@@ -130,7 +137,7 @@ class MsgSplitter:
|
|||||||
|
|
||||||
async def do_fallback(reader, writer, relay_init, label,
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
|
dc: int, is_test_dc: bool, is_media: bool, media_tag: str,
|
||||||
ctx: CryptoCtx, splitter=None):
|
ctx: CryptoCtx, splitter=None, *, h2_pool=None, proto_tag=None):
|
||||||
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
|
ip_table = DC_TEST_IPS if is_test_dc else DC_DEFAULT_IPS
|
||||||
fallback_dst = ip_table.get(dc)
|
fallback_dst = ip_table.get(dc)
|
||||||
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
|
use_cf = proxy_config.fallback_cfproxy and not is_test_dc
|
||||||
@@ -154,6 +161,13 @@ async def do_fallback(reader, writer, relay_init, label,
|
|||||||
if ok:
|
if ok:
|
||||||
return True
|
return True
|
||||||
elif method == 'cf':
|
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(
|
ok = await _cfproxy_fallback(
|
||||||
reader, writer, relay_init, label, ctx,
|
reader, writer, relay_init, label, ctx,
|
||||||
dc=dc, is_media=is_media,
|
dc=dc, is_media=is_media,
|
||||||
@@ -161,8 +175,6 @@ async def do_fallback(reader, writer, relay_init, label,
|
|||||||
if ok:
|
if ok:
|
||||||
return True
|
return True
|
||||||
elif method == 'tcp' and fallback_dst:
|
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(
|
ok = await _tcp_fallback(
|
||||||
reader, writer, fallback_dst, 443,
|
reader, writer, fallback_dst, 443,
|
||||||
relay_init, label, ctx)
|
relay_init, label, ctx)
|
||||||
@@ -231,6 +243,7 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
|
|||||||
|
|
||||||
log.info("[%s] DC%d%s -> trying CF proxy",
|
log.info("[%s] DC%d%s -> trying CF proxy",
|
||||||
label, dc, media_tag)
|
label, dc, media_tag)
|
||||||
|
connect_started = time.monotonic()
|
||||||
|
|
||||||
for base_domain in balancer.get_domains_for_dc(dc):
|
for base_domain in balancer.get_domains_for_dc(dc):
|
||||||
domain = f'kws{dc}.{base_domain}'
|
domain = f'kws{dc}.{base_domain}'
|
||||||
@@ -246,6 +259,8 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
|
|||||||
if ws is None:
|
if ws is None:
|
||||||
return False
|
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):
|
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
|
||||||
log.info("[%s] Switched active CF domain", label)
|
log.info("[%s] Switched active CF domain", label)
|
||||||
|
|
||||||
@@ -258,19 +273,45 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
|
|||||||
|
|
||||||
|
|
||||||
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
||||||
try:
|
key = (dst, port)
|
||||||
rr, rw = await asyncio.wait_for(
|
if key in _tcp_connecting or time.monotonic() < _tcp_retry_after.get(key, 0):
|
||||||
asyncio.open_connection(dst, port), timeout=10)
|
log.debug("[%s] TCP fallback to %s:%d skipped (backoff or connecting)",
|
||||||
except Exception as exc:
|
label, dst, port)
|
||||||
log.warning("[%s] TCP fallback to %s:%d failed: %s",
|
|
||||||
label, dst, port, repr(exc))
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
stats.connections_tcp_fallback += 1
|
_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)
|
rw.write(relay_init)
|
||||||
await rw.drain()
|
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)
|
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
|
||||||
return True
|
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,
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
@@ -290,6 +331,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
down_packets = 0
|
down_packets = 0
|
||||||
start_time = asyncio.get_running_loop().time()
|
start_time = asyncio.get_running_loop().time()
|
||||||
close_reason = 'normal'
|
close_reason = 'normal'
|
||||||
|
activity = WsActivity(label, dc_tag) if log.isEnabledFor(logging.DEBUG) else None
|
||||||
|
|
||||||
async def tcp_to_ws():
|
async def tcp_to_ws():
|
||||||
nonlocal up_bytes, up_packets, close_reason
|
nonlocal up_bytes, up_packets, close_reason
|
||||||
@@ -300,24 +342,38 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
if splitter:
|
if splitter:
|
||||||
tail = splitter.flush()
|
tail = splitter.flush()
|
||||||
if tail:
|
if tail:
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
await ws.send(tail[0])
|
await ws.send(tail[0])
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(len(tail[0]))
|
||||||
break
|
break
|
||||||
n = len(chunk)
|
n = len(chunk)
|
||||||
|
if activity is not None:
|
||||||
|
activity.input(n)
|
||||||
stats.bytes_up += n
|
stats.bytes_up += n
|
||||||
up_bytes += n
|
up_bytes += n
|
||||||
up_packets += 1
|
up_packets += 1
|
||||||
plain = ctx.clt_dec.update(chunk)
|
plain = ctx.clt_dec.update(chunk)
|
||||||
chunk = ctx.tg_enc.update(plain)
|
chunk = ctx.tg_enc.update(plain)
|
||||||
if splitter:
|
if splitter:
|
||||||
parts = splitter.split(chunk)
|
parts = splitter.split(chunk, plain)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
await ws.send_batch(parts)
|
await ws.send_batch(parts)
|
||||||
else:
|
else:
|
||||||
await ws.send(parts[0])
|
await ws.send(parts[0])
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(sum(map(len, parts)))
|
||||||
else:
|
else:
|
||||||
|
if activity is not None:
|
||||||
|
activity.send_started()
|
||||||
await ws.send(chunk)
|
await ws.send(chunk)
|
||||||
|
if activity is not None:
|
||||||
|
activity.sent(len(chunk))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
except (ConnectionError, OSError) as e:
|
except (ConnectionError, OSError) as e:
|
||||||
@@ -336,6 +392,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
close_reason = 'upstream: ws_close'
|
close_reason = 'upstream: ws_close'
|
||||||
break
|
break
|
||||||
n = len(data)
|
n = len(data)
|
||||||
|
if activity is not None:
|
||||||
|
activity.received(n)
|
||||||
stats.bytes_down += n
|
stats.bytes_down += n
|
||||||
down_bytes += n
|
down_bytes += n
|
||||||
down_packets += 1
|
down_packets += 1
|
||||||
@@ -343,6 +401,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
data = ctx.clt_enc.update(plain)
|
data = ctx.clt_enc.update(plain)
|
||||||
writer.write(data)
|
writer.write(data)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
if activity is not None:
|
||||||
|
activity.delivered(n)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
except (ConnectionError, OSError) as e:
|
except (ConnectionError, OSError) as e:
|
||||||
@@ -366,6 +426,8 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
elapsed = asyncio.get_running_loop().time() - start_time
|
elapsed = asyncio.get_running_loop().time() - start_time
|
||||||
|
if activity is not None:
|
||||||
|
activity.close()
|
||||||
log.info("[%s] %s WS session closed (%s): "
|
log.info("[%s] %s WS session closed (%s): "
|
||||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||||
label, dc_tag, close_reason,
|
label, dc_tag, close_reason,
|
||||||
|
|||||||
+998
@@ -0,0 +1,998 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .balancer import balancer
|
||||||
|
from .h2_transport import H2Transport
|
||||||
|
from .stats import stats
|
||||||
|
from .utils import (
|
||||||
|
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
|
||||||
|
PROTO_TAG_SECURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
MAX_PACKET = 4 * 1024 * 1024
|
||||||
|
MAX_PLAIN_PADDING = 256
|
||||||
|
MAX_CHANNEL_REQUESTS = 8
|
||||||
|
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_LANE_REQUESTS = 64
|
||||||
|
MAX_LANE_BYTES = 32 * 1024 * 1024
|
||||||
|
MAX_LANE_REPLY_BYTES = 32 * 1024 * 1024
|
||||||
|
DOMAIN_COOLDOWN = 30.0
|
||||||
|
REQUEST_TIMEOUT = 65.0
|
||||||
|
SETUP_TIMEOUT = 8.0
|
||||||
|
REPLAY_IDLE_SECONDS = 1.0
|
||||||
|
REPLAY_REQUEST_SECONDS = 3.0
|
||||||
|
REPLAY_CHECK_SECONDS = 0.25
|
||||||
|
REPLAY_MAX_AGE = 30.0
|
||||||
|
REPLAY_HISTORY_BYTES = 64 * 1024
|
||||||
|
REPLAY_HISTORY_PACKETS = 16
|
||||||
|
MAX_CHANNEL_REPLAYS = 2
|
||||||
|
REPLAY_SLOT_SECONDS = 30.0
|
||||||
|
REPLAY_RETRY_SECONDS = 2.0
|
||||||
|
REPLAY_MAX_ATTEMPTS = 3
|
||||||
|
SLOW_RESPONSE_SECONDS = 3.0
|
||||||
|
DIAGNOSTIC_INTERVAL = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _RequestTrace:
|
||||||
|
packet: int
|
||||||
|
started: float
|
||||||
|
replay: bool
|
||||||
|
size: int
|
||||||
|
request: int = 0
|
||||||
|
stream: int = 0
|
||||||
|
phase: str = 'queued'
|
||||||
|
sent_at: Optional[float] = None
|
||||||
|
headers_at: Optional[float] = None
|
||||||
|
finished_at: Optional[float] = None
|
||||||
|
received: int = 0
|
||||||
|
cf_ray: str = '-'
|
||||||
|
|
||||||
|
def summary(self, now: float) -> str:
|
||||||
|
end = self.finished_at if self.finished_at is not None else now
|
||||||
|
headers = self.headers_at if self.headers_at is not None else end
|
||||||
|
send_ms = -1 if self.sent_at is None else (self.sent_at - self.started) * 1000
|
||||||
|
headers_ms = -1 if self.sent_at is None else max(0, headers - self.sent_at) * 1000
|
||||||
|
return ('req=%d pkt=%d sid=%d replay=%d phase=%s age_ms=%.0f '
|
||||||
|
'send_ms=%.0f wait_headers_ms=%.0f up=%d down=%d ray=%s' % (
|
||||||
|
self.request, self.packet, self.stream, self.replay, self.phase,
|
||||||
|
(end - self.started) * 1000,
|
||||||
|
send_ms, headers_ms,
|
||||||
|
self.size, self.received, self.cf_ray))
|
||||||
|
|
||||||
|
|
||||||
|
class _NativeDeliveryError(ConnectionError):
|
||||||
|
"""The local receiver disconnected"""
|
||||||
|
|
||||||
|
|
||||||
|
class _ReplyBufferFull(BufferError):
|
||||||
|
"""A local memory limit"""
|
||||||
|
|
||||||
|
|
||||||
|
class _MTProtoTransportError(Exception):
|
||||||
|
"""An endpoint error that the native client must receive, not just EOF."""
|
||||||
|
|
||||||
|
def __init__(self, code: int, source: str):
|
||||||
|
super().__init__('%d (%s)' % (code, source))
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ReplayPacket:
|
||||||
|
sent_at: float
|
||||||
|
body: bytes
|
||||||
|
sequence: int
|
||||||
|
attempts: int = 0
|
||||||
|
last_attempt_at: float = 0.0
|
||||||
|
pending: Optional[asyncio.Task] = None
|
||||||
|
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
|
||||||
|
retired: bool = False
|
||||||
|
reply_id: Optional[bytes] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def replayed(self) -> bool:
|
||||||
|
return self.last_attempt_at > 0 or self.attempts > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
|
||||||
|
async def plaintext(count: int) -> bytes:
|
||||||
|
return decryptor.update(await reader.readexactly(count))
|
||||||
|
|
||||||
|
if tag == PROTO_TAG_ABRIDGED:
|
||||||
|
first = (await plaintext(1))[0]
|
||||||
|
quick, words = bool(first & 0x80), first & 0x7f
|
||||||
|
if words == 0x7f:
|
||||||
|
words = int.from_bytes(await plaintext(3), 'little')
|
||||||
|
length = words * 4
|
||||||
|
else:
|
||||||
|
value, = struct.unpack('<I', await plaintext(4))
|
||||||
|
quick, length = bool(value & 0x80000000), value & 0x7fffffff
|
||||||
|
if not 24 <= length <= MAX_PACKET + (MAX_PLAIN_PADDING if tag == PROTO_TAG_SECURE else 0):
|
||||||
|
raise ValueError('native packet length outside supported range: %d' % length)
|
||||||
|
body = await plaintext(length)
|
||||||
|
if tag == PROTO_TAG_SECURE:
|
||||||
|
if body[:8] == b'\x00' * 8:
|
||||||
|
packet_length = 20 + int.from_bytes(body[16:20], 'little')
|
||||||
|
max_padding = MAX_PLAIN_PADDING
|
||||||
|
else:
|
||||||
|
packet_length = 24 + ((length - 24) // 16) * 16
|
||||||
|
max_padding = 15
|
||||||
|
padding = length - packet_length
|
||||||
|
if not 24 <= packet_length <= length or padding > max_padding:
|
||||||
|
raise ValueError('invalid padded native packet: frame=%d packet=%d padding=%d limit=%d' % (
|
||||||
|
length, packet_length, padding, max_padding))
|
||||||
|
body = body[:packet_length]
|
||||||
|
if len(body) % 4 or len(body) > MAX_PACKET:
|
||||||
|
raise ValueError('invalid native packet alignment or size')
|
||||||
|
return body, quick
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_reply(body: bytes, tag: bytes) -> bytes:
|
||||||
|
if not body or len(body) % 4 or len(body) > MAX_PACKET:
|
||||||
|
raise ValueError('invalid HTTP MTProto response length: %d' % len(body))
|
||||||
|
if tag == PROTO_TAG_ABRIDGED:
|
||||||
|
words = len(body) // 4
|
||||||
|
prefix = bytes([words]) if words < 0x7f else b'\x7f' + words.to_bytes(3, 'little')
|
||||||
|
return prefix + body
|
||||||
|
padding = os.urandom(os.urandom(1)[0] % 4) if tag == PROTO_TAG_SECURE else b''
|
||||||
|
return struct.pack('<I', len(body) + len(padding)) + body + padding
|
||||||
|
|
||||||
|
|
||||||
|
class _HttpChannel:
|
||||||
|
def __init__(self, lane, channel_id: int, label: str):
|
||||||
|
self.lane, self.channel_id, self.label = lane, channel_id, label
|
||||||
|
self.closed = False
|
||||||
|
self.transport_error: Optional[int] = None
|
||||||
|
self.pending: Dict[asyncio.Task, int] = {}
|
||||||
|
self.pending_since: Dict[asyncio.Task, float] = {}
|
||||||
|
self.sent_since: Dict[asyncio.Task, float] = {}
|
||||||
|
self.pending_bytes = 0
|
||||||
|
self.reply_bytes = 0
|
||||||
|
self.queue = asyncio.Queue(maxsize=16)
|
||||||
|
self.capacity = asyncio.Condition()
|
||||||
|
self.recovery_wakeup = asyncio.Event()
|
||||||
|
self.requests = self.up = self.down = self.quick_requests = 0
|
||||||
|
self.started = time.monotonic()
|
||||||
|
self.receiving_tasks: Set[asyncio.Task] = set()
|
||||||
|
self.delivering_since: Optional[float] = None
|
||||||
|
self.replay_pending: Set[asyncio.Task] = set()
|
||||||
|
self.replay_history: List[_ReplayPacket] = []
|
||||||
|
self.replay_key: Optional[bytes] = None
|
||||||
|
self.replay_requests = 0
|
||||||
|
self.poll_requests = 0
|
||||||
|
self.first_poll_gap = 0.0
|
||||||
|
self.replay_rotations = 0
|
||||||
|
self.capacity_rotations = 0
|
||||||
|
self.last_progress = self.started
|
||||||
|
self.last_native_send = self.started
|
||||||
|
self.close_lock = asyncio.Lock()
|
||||||
|
self.send_lock = asyncio.Lock()
|
||||||
|
self.close_done = False
|
||||||
|
self.request_traces: Dict[asyncio.Task, _RequestTrace] = {}
|
||||||
|
self.recent_traces = deque(maxlen=16)
|
||||||
|
self.upload_wait_since: Optional[float] = None
|
||||||
|
self.upload_wait_phase = '-'
|
||||||
|
self.upload_wait_bytes = 0
|
||||||
|
self.http_bytes = self.small_responses = self.large_responses = 0
|
||||||
|
self.flow_sample = (self.started, 0, 0, 0, 0, 0, 0, 0)
|
||||||
|
lane.channels.add(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def receiving_http(self) -> int:
|
||||||
|
return len(self.receiving_tasks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_backpressure(self) -> bool:
|
||||||
|
return bool(self.reply_bytes) or self.delivering_since is not None
|
||||||
|
|
||||||
|
def _has_capacity(self, length: int) -> bool:
|
||||||
|
return len(self.pending) < MAX_CHANNEL_REQUESTS and self.pending_bytes + length <= MAX_CHANNEL_BYTES
|
||||||
|
|
||||||
|
def _wake_receiver(self) -> None:
|
||||||
|
if not self.closed and not self.pending and not self.native_backpressure:
|
||||||
|
self.recovery_wakeup.set()
|
||||||
|
|
||||||
|
def _overdue_original(self, entry: _ReplayPacket, now: float) -> bool:
|
||||||
|
return entry.retired or any(task in self.sent_since and task not in self.receiving_tasks
|
||||||
|
and now - self.sent_since[task] >= REPLAY_REQUEST_SECONDS
|
||||||
|
for task in entry.originals)
|
||||||
|
|
||||||
|
def _replay_limit(self, entry: _ReplayPacket) -> int:
|
||||||
|
return REPLAY_MAX_ATTEMPTS if entry.originals or entry.retired or entry is self.replay_history[-1] else 0
|
||||||
|
|
||||||
|
def _capacity_candidate(self, now: float):
|
||||||
|
for task, sent in sorted(self.sent_since.items(), key=lambda pair: (
|
||||||
|
pair[0] not in self.replay_pending, pair[1])):
|
||||||
|
if (task.done() or task in self.receiving_tasks
|
||||||
|
or now - sent < REPLAY_REQUEST_SECONDS):
|
||||||
|
continue
|
||||||
|
if task in self.replay_pending:
|
||||||
|
return task, None
|
||||||
|
entry = next((entry for entry in self.replay_history if task in entry.originals), None)
|
||||||
|
if entry is not None and now - entry.sent_at <= REPLAY_MAX_AGE:
|
||||||
|
return task, entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _make_upload_room(self, now: float) -> None:
|
||||||
|
if self.closed or self.upload_wait_since is None:
|
||||||
|
return
|
||||||
|
if self.upload_wait_phase == 'channel-capacity':
|
||||||
|
if self._has_capacity(self.upload_wait_bytes):
|
||||||
|
return
|
||||||
|
channels = [self]
|
||||||
|
elif self.upload_wait_phase == 'lane-capacity':
|
||||||
|
if self.lane._has_capacity(self.upload_wait_bytes):
|
||||||
|
return
|
||||||
|
channels = sorted(self.lane.channels, key=lambda channel: min(
|
||||||
|
channel.sent_since.values(), default=now))
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
for channel in channels:
|
||||||
|
candidate = channel._capacity_candidate(now)
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
task, entry = candidate
|
||||||
|
channel.sent_since.pop(task, None)
|
||||||
|
if entry is not None:
|
||||||
|
entry.retired = True
|
||||||
|
trace = channel.request_traces.get(task)
|
||||||
|
if trace is not None:
|
||||||
|
trace.phase = 'capacity-' + trace.phase
|
||||||
|
if now >= self.lane.next_capacity_log:
|
||||||
|
self.lane.next_capacity_log = now + DIAGNOSTIC_INTERVAL
|
||||||
|
log.debug('H2 CAPACITY lane=%d waiting_channel=%d release_channel=%d '
|
||||||
|
'reason=%s replay=%d wait_ms=%.0f inflight=%d',
|
||||||
|
self.lane.lane_id, self.channel_id, channel.channel_id,
|
||||||
|
self.upload_wait_phase, task in channel.replay_pending,
|
||||||
|
(now - self.upload_wait_since) * 1000, self.lane.inflight)
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
channel.capacity_rotations += 1
|
||||||
|
self.lane.capacity_rotations += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
def _replay_blocker(self, entry: _ReplayPacket, now: float) -> Optional[str]:
|
||||||
|
if not 0 <= now - entry.sent_at <= REPLAY_MAX_AGE:
|
||||||
|
return 'expired'
|
||||||
|
if entry.attempts >= self._replay_limit(entry):
|
||||||
|
return 'budget-used'
|
||||||
|
if self.native_backpressure:
|
||||||
|
return 'native-backpressure'
|
||||||
|
if entry.originals.intersection(self.receiving_tasks):
|
||||||
|
return 'original-body'
|
||||||
|
if entry.pending in self.receiving_tasks:
|
||||||
|
return 'replay-body'
|
||||||
|
if entry.replayed and now - entry.last_attempt_at < REPLAY_RETRY_SECONDS:
|
||||||
|
return 'backoff-or-progress'
|
||||||
|
if self._overdue_original(entry, now):
|
||||||
|
return None
|
||||||
|
if (entry is not self.replay_history[-1]
|
||||||
|
or any(task is not entry.pending for task in self.pending)):
|
||||||
|
return 'receiver-active'
|
||||||
|
if entry.replayed and now - self.last_progress < REPLAY_IDLE_SECONDS:
|
||||||
|
return 'backoff-or-progress'
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _replay_due(self, entry: _ReplayPacket, now: float) -> bool:
|
||||||
|
return self._replay_blocker(entry, now) is None
|
||||||
|
|
||||||
|
def _wait_state(self, now: float) -> str:
|
||||||
|
oldest = min(self.pending_since.values(), default=now)
|
||||||
|
recovery_left = sum(max(0, self._replay_limit(entry) - entry.attempts)
|
||||||
|
for entry in self.replay_history if now - entry.sent_at <= REPLAY_MAX_AGE)
|
||||||
|
waiting_headers = sum(task not in self.replay_pending and task not in self.receiving_tasks
|
||||||
|
and now - since >= REPLAY_REQUEST_SECONDS
|
||||||
|
for task, since in self.pending_since.items())
|
||||||
|
return ('channel=%d pending=%d replays=%d receiving=%d ready_bytes=%d '
|
||||||
|
'native_write=%d idle_ms=%.0f oldest_ms=%.0f native_age_ms=%.0f '
|
||||||
|
'recovery_left=%d waiting_headers=%d' % (
|
||||||
|
self.channel_id, len(self.pending), len(self.replay_pending),
|
||||||
|
self.receiving_http, self.reply_bytes, self.delivering_since is not None,
|
||||||
|
(now - self.last_progress) * 1000, (now - oldest) * 1000,
|
||||||
|
(now - self.last_native_send) * 1000, recovery_left, waiting_headers))
|
||||||
|
|
||||||
|
def _request_detail(self, trace: _RequestTrace, now: float) -> str:
|
||||||
|
entry = next((entry for entry in self.replay_history if entry.sequence == trace.packet), None)
|
||||||
|
recovery = 'not-retained'
|
||||||
|
attempts = '-'
|
||||||
|
if entry is not None:
|
||||||
|
attempts = '%d/%d' % (entry.attempts, self._replay_limit(entry))
|
||||||
|
blocker = self._replay_blocker(entry, now)
|
||||||
|
if blocker is not None:
|
||||||
|
recovery = blocker
|
||||||
|
elif entry.pending is not None:
|
||||||
|
recovery = ('replay-body' if entry.pending in self.receiving_tasks else
|
||||||
|
'probe-%.0fms' % ((now - self.pending_since.get(entry.pending, now)) * 1000))
|
||||||
|
elif len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
|
||||||
|
recovery = 'replay-slots'
|
||||||
|
elif not self._has_capacity(len(entry.body)):
|
||||||
|
recovery = 'channel-capacity'
|
||||||
|
elif not self.lane._has_capacity(len(entry.body)):
|
||||||
|
recovery = 'lane-capacity'
|
||||||
|
elif self.send_lock.locked():
|
||||||
|
recovery = 'send-lock'
|
||||||
|
else:
|
||||||
|
recovery = 'eligible'
|
||||||
|
related = [item for item in self.recent_traces if item.packet == trace.packet][-2:]
|
||||||
|
return ('channel=%d %s attempts=%s recovery=%s recent=[%s]' % (
|
||||||
|
self.channel_id, trace.summary(now), attempts, recovery,
|
||||||
|
'; '.join(item.summary(now) for item in related)))
|
||||||
|
|
||||||
|
def _flow_state(self, now: float) -> str:
|
||||||
|
previous = self.flow_sample
|
||||||
|
current = (now, self.requests, self.replay_requests, self.http_bytes,
|
||||||
|
self.down, self.small_responses, self.large_responses, self.poll_requests)
|
||||||
|
self.flow_sample = current
|
||||||
|
poll_gap, self.first_poll_gap = self.first_poll_gap, 0.0
|
||||||
|
probes = [trace.summary(now) for trace in self.request_traces.values() if trace.replay][:2]
|
||||||
|
return ('channel=%d window_ms=%.0f requests=%d replays=%d http_down=%d native_down=%d '
|
||||||
|
'small=%d large=%d pending=%d ready=%d upload_wait=%s/%.0fms native_write_ms=%.0f '
|
||||||
|
'capacity_rotations=%d polls=%d first_poll_ms=%.0f probes=[%s]' % (
|
||||||
|
self.channel_id, (now - previous[0]) * 1000,
|
||||||
|
*(current[index] - previous[index] for index in range(1, 7)),
|
||||||
|
len(self.pending), self.reply_bytes, self.upload_wait_phase,
|
||||||
|
0 if self.upload_wait_since is None else (now - self.upload_wait_since) * 1000,
|
||||||
|
0 if self.delivering_since is None else (now - self.delivering_since) * 1000,
|
||||||
|
self.capacity_rotations,
|
||||||
|
current[7] - previous[7], poll_gap * 1000,
|
||||||
|
'; '.join(probes)))
|
||||||
|
|
||||||
|
def _fail(self, error: Exception) -> None:
|
||||||
|
if self.closed:
|
||||||
|
return
|
||||||
|
if isinstance(error, _MTProtoTransportError):
|
||||||
|
self.transport_error = error.code
|
||||||
|
self.closed = True
|
||||||
|
self.recovery_wakeup.set()
|
||||||
|
while not self.queue.empty():
|
||||||
|
self.queue.get_nowait()
|
||||||
|
self.reply_bytes = 0
|
||||||
|
self.queue.put_nowait(error)
|
||||||
|
|
||||||
|
async def send(self, body: bytes, quick: bool, replay: bool = False) -> bool:
|
||||||
|
if replay and self.send_lock.locked():
|
||||||
|
return False
|
||||||
|
if not replay:
|
||||||
|
self.upload_wait_since = time.monotonic()
|
||||||
|
self.upload_wait_phase = 'send-lock'
|
||||||
|
self.upload_wait_bytes = len(body)
|
||||||
|
try:
|
||||||
|
async with self.send_lock:
|
||||||
|
return await self._send(body, quick, replay)
|
||||||
|
finally:
|
||||||
|
if not replay:
|
||||||
|
self.upload_wait_since = None
|
||||||
|
self.upload_wait_phase = '-'
|
||||||
|
self.upload_wait_bytes = 0
|
||||||
|
|
||||||
|
async def _send(self, body: bytes, quick: bool, replay: bool) -> bool:
|
||||||
|
async with self.capacity:
|
||||||
|
if replay and not self._has_capacity(len(body)):
|
||||||
|
return False
|
||||||
|
if not replay:
|
||||||
|
self.upload_wait_phase = 'channel-capacity'
|
||||||
|
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(len(body))),
|
||||||
|
REQUEST_TIMEOUT)
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError('H2 channel closed')
|
||||||
|
if not replay:
|
||||||
|
self.upload_wait_phase = 'lane-capacity'
|
||||||
|
if not await self.lane._reserve(len(body), wait=not replay):
|
||||||
|
return False
|
||||||
|
if self.closed:
|
||||||
|
await self.lane._release(len(body))
|
||||||
|
raise ConnectionError('H2 channel closed')
|
||||||
|
replay_packet = self._find_packet(body) if replay else None
|
||||||
|
now = time.monotonic()
|
||||||
|
if replay and (replay_packet is None or replay_packet.pending is not None
|
||||||
|
or not self._replay_due(replay_packet, now)):
|
||||||
|
await self.lane._release(len(body))
|
||||||
|
return False
|
||||||
|
self.pending_bytes += len(body)
|
||||||
|
self.requests += 1
|
||||||
|
self.replay_requests += int(replay)
|
||||||
|
self.up += len(body)
|
||||||
|
stats.bytes_up += len(body)
|
||||||
|
if not replay:
|
||||||
|
self.last_native_send = now
|
||||||
|
native_packet = self._remember_packet(body, now) if not replay else None
|
||||||
|
if quick:
|
||||||
|
self.quick_requests += 1
|
||||||
|
packet = replay_packet or native_packet
|
||||||
|
trace = (_RequestTrace(packet.sequence if packet is not None else self.requests,
|
||||||
|
now, replay, len(body))
|
||||||
|
if log.isEnabledFor(logging.DEBUG) else None)
|
||||||
|
task = asyncio.create_task(self._run_request(body, replay, packet, trace))
|
||||||
|
self.pending[task] = len(body)
|
||||||
|
self.pending_since[task] = now
|
||||||
|
if trace is not None:
|
||||||
|
self.request_traces[task] = trace
|
||||||
|
if native_packet is not None:
|
||||||
|
native_packet.originals.add(task)
|
||||||
|
self._trim_history(now)
|
||||||
|
if replay:
|
||||||
|
if not self._overdue_original(replay_packet, now):
|
||||||
|
self.poll_requests += 1
|
||||||
|
if not replay_packet.replayed:
|
||||||
|
self.first_poll_gap = max(self.first_poll_gap, now - self.last_progress)
|
||||||
|
self.replay_pending.add(task)
|
||||||
|
replay_packet.attempts += 1
|
||||||
|
replay_packet.last_attempt_at = now
|
||||||
|
replay_packet.pending = task
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _find_packet(self, body: bytes) -> Optional[_ReplayPacket]:
|
||||||
|
return next((entry for entry in self.replay_history if entry.body == body), None)
|
||||||
|
|
||||||
|
def _remember_packet(self, body: bytes, now: float) -> Optional[_ReplayPacket]:
|
||||||
|
key = body[:8]
|
||||||
|
if key == b'\x00' * 8:
|
||||||
|
return None
|
||||||
|
if self.replay_key != key:
|
||||||
|
self.replay_history.clear()
|
||||||
|
self.replay_key = key
|
||||||
|
if len(body) > REPLAY_HISTORY_BYTES:
|
||||||
|
return None
|
||||||
|
packet = self._find_packet(body)
|
||||||
|
if packet is None:
|
||||||
|
packet = _ReplayPacket(now, body, self.requests)
|
||||||
|
self.replay_history.append(packet)
|
||||||
|
return packet
|
||||||
|
|
||||||
|
def _trim_history(self, now: float) -> None:
|
||||||
|
retained_bytes = sum(len(entry.body) for entry in self.replay_history)
|
||||||
|
while len(self.replay_history) > REPLAY_HISTORY_PACKETS or retained_bytes > REPLAY_HISTORY_BYTES:
|
||||||
|
victim = next((entry for entry in self.replay_history if not entry.originals
|
||||||
|
and not (entry.retired and entry.attempts < REPLAY_MAX_ATTEMPTS
|
||||||
|
and now - entry.sent_at <= REPLAY_MAX_AGE)),
|
||||||
|
self.replay_history[0])
|
||||||
|
self.replay_history.remove(victim)
|
||||||
|
retained_bytes -= len(victim.body)
|
||||||
|
|
||||||
|
async def _run_request(self, body: bytes, replay: bool, packet: Optional[_ReplayPacket],
|
||||||
|
trace: Optional[_RequestTrace]) -> None:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
try:
|
||||||
|
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
|
||||||
|
self.last_progress = time.monotonic()
|
||||||
|
if reply and not self.closed:
|
||||||
|
if packet is not None and reply[:24] != packet.reply_id:
|
||||||
|
packet.reply_id = reply[:24]
|
||||||
|
packet.attempts = 0
|
||||||
|
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
|
||||||
|
raise BufferError('H2 client response queue exceeded bound')
|
||||||
|
self.reply_bytes += len(reply)
|
||||||
|
self.queue.put_nowait(reply)
|
||||||
|
if trace is not None:
|
||||||
|
trace.phase = 'complete'
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if trace is not None:
|
||||||
|
trace.phase = 'cancelled-' + trace.phase
|
||||||
|
raise
|
||||||
|
except _MTProtoTransportError as exc:
|
||||||
|
if trace is not None:
|
||||||
|
trace.phase = 'transport-error'
|
||||||
|
stats.h2_errors += 1
|
||||||
|
if not self.closed:
|
||||||
|
log.warning('[%s] H2 lane=%d channel=%d host=%s MTProto transport error=%s '
|
||||||
|
'request=%d encrypted=%d bytes=%d replay=%d; reporting to client',
|
||||||
|
self.label, self.lane.lane_id, self.channel_id, self.lane.host, exc,
|
||||||
|
trace.request if trace is not None else 0,
|
||||||
|
body[:8] != b'\x00' * 8, len(body), replay)
|
||||||
|
self._fail(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
if trace is not None:
|
||||||
|
trace.phase = 'error-' + type(exc).__name__
|
||||||
|
stats.h2_errors += 1
|
||||||
|
if isinstance(exc, (httpx.HTTPError, OSError, ValueError, BufferError)):
|
||||||
|
log.warning('[%s] H2 lane=%d channel=%d request failed: %s: %s; '
|
||||||
|
'detail=[%s]; '
|
||||||
|
'closing native channel, reconnect may select another domain',
|
||||||
|
self.label, self.lane.lane_id, self.channel_id, type(exc).__name__, exc,
|
||||||
|
trace.summary(time.monotonic()) if trace is not None else '-')
|
||||||
|
else:
|
||||||
|
log.exception('[%s] H2 lane=%d channel=%d unexpected request failure',
|
||||||
|
self.label, self.lane.lane_id, self.channel_id)
|
||||||
|
self._fail(exc)
|
||||||
|
finally:
|
||||||
|
await self._finish_request(task, packet)
|
||||||
|
|
||||||
|
async def _finish_request(self, task: asyncio.Task, packet: Optional[_ReplayPacket] = None) -> None:
|
||||||
|
length = self.pending.pop(task, None)
|
||||||
|
if length is None:
|
||||||
|
return
|
||||||
|
trace = self.request_traces.pop(task, None)
|
||||||
|
if trace is not None:
|
||||||
|
trace.finished_at = time.monotonic()
|
||||||
|
self.recent_traces.append(trace)
|
||||||
|
self.pending_bytes -= length
|
||||||
|
self.pending_since.pop(task, None)
|
||||||
|
self.sent_since.pop(task, None)
|
||||||
|
self.replay_pending.discard(task)
|
||||||
|
if packet is None:
|
||||||
|
packet = next((entry for entry in self.replay_history
|
||||||
|
if task in entry.originals or entry.pending is task), None)
|
||||||
|
if packet is not None:
|
||||||
|
packet.originals.discard(task)
|
||||||
|
if packet.pending is task:
|
||||||
|
packet.pending = None
|
||||||
|
await self.lane._release(length)
|
||||||
|
async with self.capacity:
|
||||||
|
self.capacity.notify_all()
|
||||||
|
self._wake_receiver()
|
||||||
|
|
||||||
|
async def _recover(self) -> None:
|
||||||
|
while not self.closed:
|
||||||
|
wakeup = asyncio.create_task(self.recovery_wakeup.wait())
|
||||||
|
try:
|
||||||
|
# wait_for can swallow cancellation when the wakeup completes
|
||||||
|
# at the same time on Python 3.8-3.11 (CPython #86296).
|
||||||
|
await asyncio.wait((wakeup,), timeout=REPLAY_CHECK_SECONDS)
|
||||||
|
finally:
|
||||||
|
wakeup.cancel()
|
||||||
|
await asyncio.gather(wakeup, return_exceptions=True)
|
||||||
|
self.recovery_wakeup.clear()
|
||||||
|
now = time.monotonic()
|
||||||
|
self.lane.log_wait(now)
|
||||||
|
await self._make_upload_room(now)
|
||||||
|
if self.closed or self.native_backpressure:
|
||||||
|
continue
|
||||||
|
def priority(entry):
|
||||||
|
overdue = self._overdue_original(entry, now)
|
||||||
|
return (not overdue, entry.sequence if overdue else -entry.sequence)
|
||||||
|
|
||||||
|
candidates = sorted(self.replay_history, key=priority)
|
||||||
|
for entry in candidates:
|
||||||
|
if not self._replay_due(entry, now):
|
||||||
|
continue
|
||||||
|
replace = entry.pending
|
||||||
|
if replace is None and len(self.replay_pending) >= MAX_CHANNEL_REPLAYS:
|
||||||
|
replace = next((task for task in self.pending if task in self.replay_pending
|
||||||
|
and task not in self.receiving_tasks), None)
|
||||||
|
if replace is None:
|
||||||
|
continue
|
||||||
|
if replace is not None:
|
||||||
|
sent = self.sent_since.get(replace)
|
||||||
|
if (replace in self.receiving_tasks
|
||||||
|
or sent is None or now - sent < REPLAY_SLOT_SECONDS):
|
||||||
|
continue
|
||||||
|
replace.cancel()
|
||||||
|
await asyncio.gather(replace, return_exceptions=True)
|
||||||
|
self.replay_rotations += 1
|
||||||
|
await self.send(entry.body, False, replay=True)
|
||||||
|
break
|
||||||
|
|
||||||
|
async def receive(self) -> bytes:
|
||||||
|
value = await self.queue.get()
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
raise value
|
||||||
|
self.reply_bytes -= len(value)
|
||||||
|
self._wake_receiver()
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
async with self.close_lock:
|
||||||
|
if self.close_done:
|
||||||
|
return
|
||||||
|
waiting = self._wait_state(time.monotonic())
|
||||||
|
if log.isEnabledFor(logging.DEBUG) and self.request_traces:
|
||||||
|
now = time.monotonic()
|
||||||
|
traces = sorted(self.request_traces.values(), key=lambda trace: trace.started)[:3]
|
||||||
|
log.debug('H2 DETAIL CLOSE lane=%d [%s]', self.lane.lane_id,
|
||||||
|
'; '.join(self._request_detail(trace, now) for trace in traces))
|
||||||
|
self._fail(ConnectionError('H2 channel closed'))
|
||||||
|
self.closed = True
|
||||||
|
tasks = list(self.pending)
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for task in list(self.pending):
|
||||||
|
await self._finish_request(task)
|
||||||
|
self.replay_pending.clear()
|
||||||
|
self.replay_history.clear()
|
||||||
|
self.sent_since.clear()
|
||||||
|
self.request_traces.clear()
|
||||||
|
self.recent_traces.clear()
|
||||||
|
self.lane.channels.discard(self)
|
||||||
|
self.close_done = True
|
||||||
|
log.debug('[%s] H2 channel=%d closed lane=%d requests=%d replays=%d rotations=%d capacity_rotations=%d up=%d down=%d '
|
||||||
|
'quickack=%d duration=%.1fs remaining_channels=%d; %s',
|
||||||
|
self.label, self.channel_id, self.lane.lane_id, self.requests,
|
||||||
|
self.replay_requests, self.replay_rotations, self.capacity_rotations, self.up, self.down, self.quick_requests,
|
||||||
|
time.monotonic() - self.started, len(self.lane.channels), waiting)
|
||||||
|
|
||||||
|
class _HttpLane:
|
||||||
|
def __init__(self, host: str, lane_id: int):
|
||||||
|
self.host, self.lane_id = host, lane_id
|
||||||
|
self.channels: Set[_HttpChannel] = set()
|
||||||
|
self.failed_until = 0.0
|
||||||
|
self.closed = False
|
||||||
|
self.tcp_connections = self.requests = self.responses = self.errors = 0
|
||||||
|
self.slow_headers = self.slow_bodies = self.replays = 0
|
||||||
|
self.max_headers_seconds = self.max_body_seconds = 0.0
|
||||||
|
self.next_wait_log = self.next_slow_log = 0.0
|
||||||
|
self.next_capacity_log = 0.0
|
||||||
|
self.capacity_rotations = 0
|
||||||
|
self.next_large_slow_log = 0.0
|
||||||
|
self.inflight = self.queued_bytes = self.max_inflight = 0
|
||||||
|
self.reply_buffer_bytes = 0
|
||||||
|
self.capacity = asyncio.Condition()
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
transport=H2Transport(max_streams=MAX_LANE_REQUESTS),
|
||||||
|
trust_env=False,
|
||||||
|
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _trace(self, event: str, info: Dict) -> None:
|
||||||
|
if event == 'connection.connect_tcp.started':
|
||||||
|
self.tcp_connections += 1
|
||||||
|
stats.h2_tcp_connections += 1
|
||||||
|
log.debug('H2 lane=%d TCP_OPEN connection=%d host=%s channels=%d',
|
||||||
|
self.lane_id, self.tcp_connections, self.host, len(self.channels))
|
||||||
|
|
||||||
|
async def _preflight(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
async with self.client.stream('HEAD', 'https://' + self.host + '/api',
|
||||||
|
extensions={'trace': self._trace}) as response:
|
||||||
|
if response.http_version != 'HTTP/2':
|
||||||
|
raise ValueError('CF /api preflight negotiated ' + response.http_version)
|
||||||
|
if not (200 <= response.status_code < 300 or response.status_code in (405, 501)):
|
||||||
|
raise ConnectionError('CF /api preflight HTTP %d cf_ray=%s' % (
|
||||||
|
response.status_code, response.headers.get('cf-ray', '-')))
|
||||||
|
log.debug('H2 lane=%d ready host=%s status=%d ms=%.0f',
|
||||||
|
self.lane_id, self.host, response.status_code,
|
||||||
|
(time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
def _has_capacity(self, length: int) -> bool:
|
||||||
|
return self.inflight < MAX_LANE_REQUESTS and self.queued_bytes + length <= MAX_LANE_BYTES
|
||||||
|
|
||||||
|
async def _reserve(self, length: int, wait: bool = True) -> bool:
|
||||||
|
async with self.capacity:
|
||||||
|
if not wait and not self._has_capacity(length):
|
||||||
|
return False
|
||||||
|
if wait:
|
||||||
|
await asyncio.wait_for(self.capacity.wait_for(lambda: self.closed or self._has_capacity(length)),
|
||||||
|
REQUEST_TIMEOUT)
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError('H2 lane closed')
|
||||||
|
self.inflight += 1
|
||||||
|
self.queued_bytes += length
|
||||||
|
self.max_inflight = max(self.max_inflight, self.inflight)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _release(self, length: int) -> None:
|
||||||
|
async with self.capacity:
|
||||||
|
self.inflight -= 1
|
||||||
|
self.queued_bytes -= length
|
||||||
|
self.capacity.notify_all()
|
||||||
|
|
||||||
|
async def _post(self, body: bytes, channel_id: int, replay: bool = False,
|
||||||
|
channel: Optional[_HttpChannel] = None) -> bytes:
|
||||||
|
self.requests += 1
|
||||||
|
self.replays += int(replay)
|
||||||
|
stats.h2_requests += 1
|
||||||
|
request_id = self.requests
|
||||||
|
started = time.monotonic()
|
||||||
|
buffered = 0
|
||||||
|
receiving = False
|
||||||
|
request_task = asyncio.current_task()
|
||||||
|
trace = channel.request_traces.get(request_task) if channel is not None else None
|
||||||
|
if trace is not None:
|
||||||
|
trace.request = request_id
|
||||||
|
trace.phase = 'connect-or-slot'
|
||||||
|
|
||||||
|
async def request_trace(event, info):
|
||||||
|
await self._trace(event, info)
|
||||||
|
if event == 'http2.send_request_body.complete' and channel is not None:
|
||||||
|
channel.sent_since[request_task] = time.monotonic()
|
||||||
|
if trace is not None:
|
||||||
|
if event == 'connection.connect_tcp.started':
|
||||||
|
trace.phase = 'connect'
|
||||||
|
elif event == 'http2.wait_for_stream.started':
|
||||||
|
trace.phase = 'stream-slot'
|
||||||
|
elif event == 'http2.send_request_headers.started':
|
||||||
|
trace.stream = info['stream_id']
|
||||||
|
trace.phase = 'upload'
|
||||||
|
elif event == 'http2.send_request_body.complete':
|
||||||
|
trace.sent_at = time.monotonic()
|
||||||
|
trace.phase = 'headers' if trace.headers_at is None else 'body'
|
||||||
|
|
||||||
|
def response_started():
|
||||||
|
nonlocal receiving
|
||||||
|
if trace is not None and trace.headers_at is None:
|
||||||
|
trace.headers_at = time.monotonic()
|
||||||
|
trace.phase = 'body'
|
||||||
|
if channel is not None and not receiving:
|
||||||
|
channel.receiving_tasks.add(request_task)
|
||||||
|
channel.last_progress = time.monotonic()
|
||||||
|
receiving = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.client.stream(
|
||||||
|
'POST', 'https://' + self.host + '/api', content=body,
|
||||||
|
headers={'content-type': 'application/octet-stream', 'accept-encoding': 'identity'},
|
||||||
|
extensions={'trace': request_trace,
|
||||||
|
'h2_response_started': response_started}) as response:
|
||||||
|
headers_at = time.monotonic()
|
||||||
|
if trace is not None:
|
||||||
|
trace.cf_ray = ''.join(c for c in response.headers.get('cf-ray', '-')[:64]
|
||||||
|
if c.isascii() and (c.isalnum() or c == '-'))
|
||||||
|
self.slow_headers += int(headers_at - started >= SLOW_RESPONSE_SECONDS)
|
||||||
|
self.max_headers_seconds = max(self.max_headers_seconds, headers_at - started)
|
||||||
|
if response.http_version != 'HTTP/2':
|
||||||
|
raise ValueError('CF /api downgraded to ' + response.http_version)
|
||||||
|
if response.status_code in (403, 404, 429, 444):
|
||||||
|
raise _MTProtoTransportError(-response.status_code, 'HTTP %d cf_ray=%s' % (
|
||||||
|
response.status_code, response.headers.get('cf-ray', '-')))
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ConnectionError('CF /api HTTP %d cf_ray=%s' % (
|
||||||
|
response.status_code, response.headers.get('cf-ray', '-')))
|
||||||
|
if 'text/html' in response.headers.get('content-type', '').lower():
|
||||||
|
raise ValueError('CF /api returned HTML instead of MTProto')
|
||||||
|
response_started()
|
||||||
|
length_header = response.headers.get('content-length')
|
||||||
|
declared_length = int(length_header) if length_header is not None else None
|
||||||
|
if declared_length is not None and not 0 <= declared_length <= MAX_PACKET:
|
||||||
|
raise BufferError('H2 response Content-Length exceeds native packet bound')
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
if len(content) + len(chunk) > MAX_PACKET:
|
||||||
|
raise BufferError('H2 response exceeds native packet bound')
|
||||||
|
if self.reply_buffer_bytes + len(chunk) > MAX_LANE_REPLY_BYTES:
|
||||||
|
raise _ReplyBufferFull('H2 lane response buffers exceeded bound')
|
||||||
|
self.reply_buffer_bytes += len(chunk)
|
||||||
|
buffered += len(chunk)
|
||||||
|
content.extend(chunk)
|
||||||
|
if trace is not None:
|
||||||
|
trace.received += len(chunk)
|
||||||
|
if channel is not None:
|
||||||
|
channel.last_progress = time.monotonic()
|
||||||
|
received = len(content)
|
||||||
|
if (declared_length is not None
|
||||||
|
and response.headers.get('content-encoding', 'identity') == 'identity'
|
||||||
|
and received != declared_length):
|
||||||
|
raise ValueError('incomplete H2 response body')
|
||||||
|
if content and len(content) % 4:
|
||||||
|
raise ValueError('CF /api response is not an aligned MTProto packet')
|
||||||
|
if len(content) == 4:
|
||||||
|
code, = struct.unpack('<i', content)
|
||||||
|
if code < 0:
|
||||||
|
raise _MTProtoTransportError(code, 'HTTP 200 payload')
|
||||||
|
self.responses += 1
|
||||||
|
if channel is not None:
|
||||||
|
channel.http_bytes += received
|
||||||
|
channel.small_responses += int(received <= 4096)
|
||||||
|
channel.large_responses += int(received > 4096)
|
||||||
|
finished = time.monotonic()
|
||||||
|
body_seconds = finished - headers_at
|
||||||
|
self.max_body_seconds = max(self.max_body_seconds, body_seconds)
|
||||||
|
self.slow_bodies += int(body_seconds >= SLOW_RESPONSE_SECONDS)
|
||||||
|
next_slow = self.next_large_slow_log if received > 4096 else self.next_slow_log
|
||||||
|
if finished - started >= SLOW_RESPONSE_SECONDS and finished >= next_slow:
|
||||||
|
if received > 4096:
|
||||||
|
self.next_large_slow_log = finished + DIAGNOSTIC_INTERVAL
|
||||||
|
else:
|
||||||
|
self.next_slow_log = finished + DIAGNOSTIC_INTERVAL
|
||||||
|
log.debug('H2 SLOW lane=%d channel=%d request=%d bytes=%d replay=%d '
|
||||||
|
'headers_ms=%.0f body_ms=%.0f detail=[%s]',
|
||||||
|
self.lane_id, channel_id, request_id, received, replay,
|
||||||
|
(headers_at - started) * 1000, body_seconds * 1000,
|
||||||
|
trace.summary(finished) if trace is not None else '-')
|
||||||
|
return bytes(content)
|
||||||
|
except _MTProtoTransportError:
|
||||||
|
self.errors += 1
|
||||||
|
raise
|
||||||
|
except _ReplyBufferFull:
|
||||||
|
raise
|
||||||
|
except (httpx.HTTPError, OSError, ValueError, BufferError):
|
||||||
|
self.errors += 1
|
||||||
|
self.failed_until = time.monotonic() + DOMAIN_COOLDOWN
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.reply_buffer_bytes -= buffered
|
||||||
|
if receiving:
|
||||||
|
channel.receiving_tasks.discard(request_task)
|
||||||
|
|
||||||
|
def log_wait(self, now: float) -> None:
|
||||||
|
if not log.isEnabledFor(logging.DEBUG) or now < self.next_wait_log:
|
||||||
|
return
|
||||||
|
self.next_wait_log = now + 1.0
|
||||||
|
waiting = [channel for channel in self.channels
|
||||||
|
if ((channel.delivering_since is not None
|
||||||
|
and now - channel.delivering_since >= SLOW_RESPONSE_SECONDS) or any(
|
||||||
|
task not in channel.replay_pending
|
||||||
|
and task not in channel.receiving_tasks
|
||||||
|
and now - started >= SLOW_RESPONSE_SECONDS
|
||||||
|
for task, started in channel.pending_since.items()))
|
||||||
|
and now - channel.last_native_send <= REPLAY_MAX_AGE]
|
||||||
|
if waiting:
|
||||||
|
self.next_wait_log = now + DIAGNOSTIC_INTERVAL
|
||||||
|
waiting.sort(key=lambda channel: channel.channel_id)
|
||||||
|
log.debug('H2 WAIT lane=%d affected=%d inflight=%d body_buffer=%d [%s]',
|
||||||
|
self.lane_id, len(waiting), self.inflight, self.reply_buffer_bytes,
|
||||||
|
'; '.join(channel._wait_state(now) for channel in waiting[:8]))
|
||||||
|
details = sorted(((channel, trace) for channel in waiting for trace in channel.request_traces.values()
|
||||||
|
if not trace.replay and now - trace.started >= SLOW_RESPONSE_SECONDS),
|
||||||
|
key=lambda pair: pair[1].started)[:12]
|
||||||
|
if details:
|
||||||
|
log.debug('H2 DETAIL lane=%d [%s]', self.lane_id,
|
||||||
|
'; '.join(channel._request_detail(trace, now) for channel, trace in details))
|
||||||
|
|
||||||
|
def log_flow(self, now: float) -> None:
|
||||||
|
if log.isEnabledFor(logging.DEBUG) and self.channels:
|
||||||
|
channels = sorted(self.channels, key=lambda channel: channel.channel_id)
|
||||||
|
for offset in range(0, len(channels), 8):
|
||||||
|
log.debug('H2 FLOW lane=%d [%s]', self.lane_id,
|
||||||
|
'; '.join(channel._flow_state(now) for channel in channels[offset:offset + 8]))
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return ('lane=%d host=%s channels=%d tcp_connections=%d requests=%d replies=%d '
|
||||||
|
'errors=%d inflight=%d max_inflight=%d queued_bytes=%d reply_buffer_bytes=%d '
|
||||||
|
'replays=%d capacity_rotations=%d slow_headers=%d slow_bodies=%d max_headers_ms=%.0f max_body_ms=%.0f' % (
|
||||||
|
self.lane_id, self.host, len(self.channels), self.tcp_connections,
|
||||||
|
self.requests, self.responses, self.errors, self.inflight,
|
||||||
|
self.max_inflight, self.queued_bytes, self.reply_buffer_bytes,
|
||||||
|
self.replays, self.capacity_rotations, self.slow_headers, self.slow_bodies,
|
||||||
|
self.max_headers_seconds * 1000, self.max_body_seconds * 1000))
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
async with self.capacity:
|
||||||
|
self.capacity.notify_all()
|
||||||
|
await asyncio.gather(*(channel.close() for channel in list(self.channels)))
|
||||||
|
await self.client.aclose()
|
||||||
|
log.debug('H2 CLOSED %s', self.summary())
|
||||||
|
|
||||||
|
|
||||||
|
class CfH2Pool:
|
||||||
|
def __init__(self):
|
||||||
|
for name in ('httpx', 'httpcore', 'hpack', 'h2'):
|
||||||
|
logging.getLogger(name).setLevel(logging.WARNING)
|
||||||
|
self.lanes: Dict[str, _HttpLane] = {}
|
||||||
|
self.locks: Dict[str, asyncio.Lock] = {}
|
||||||
|
self.failed_until: Dict[str, float] = {}
|
||||||
|
self.setup_retry_after: Dict[int, float] = {}
|
||||||
|
self.next_lane = self.next_channel = 0
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def open(self, dc: int, label: str) -> Optional[_HttpChannel]:
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError('H2 pool closed')
|
||||||
|
if time.monotonic() < self.setup_retry_after.get(dc, 0):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(self._open(dc, label), SETUP_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.setup_retry_after[dc] = time.monotonic() + DOMAIN_COOLDOWN
|
||||||
|
stats.h2_errors += 1
|
||||||
|
log.warning('[%s] H2 DC%d setup timed out; using WS for %.0fs',
|
||||||
|
label, dc, DOMAIN_COOLDOWN)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _open(self, dc: int, label: str) -> Optional[_HttpChannel]:
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError('H2 pool closed')
|
||||||
|
if dc not in DC_DEFAULT_IPS:
|
||||||
|
log.warning('[%s] H2 unsupported DC%d -> existing WS route', label, dc)
|
||||||
|
return None
|
||||||
|
for base_domain in balancer.get_domains_for_dc(dc):
|
||||||
|
host = 'kws%d.%s' % (dc, base_domain)
|
||||||
|
async with self.locks.setdefault(host, asyncio.Lock()):
|
||||||
|
if self.closed:
|
||||||
|
raise ConnectionError('H2 pool closed')
|
||||||
|
lane = self.lanes.get(host)
|
||||||
|
if time.monotonic() < max(self.failed_until.get(host, 0),
|
||||||
|
lane.failed_until if lane else 0):
|
||||||
|
continue
|
||||||
|
if lane is None:
|
||||||
|
self.next_lane += 1
|
||||||
|
lane = _HttpLane(host, self.next_lane)
|
||||||
|
try:
|
||||||
|
await lane._preflight()
|
||||||
|
except (httpx.HTTPError, OSError, ValueError) as exc:
|
||||||
|
stats.h2_errors += 1
|
||||||
|
self.failed_until[host] = time.monotonic() + DOMAIN_COOLDOWN
|
||||||
|
log.warning('[%s] H2 lane=%d preflight failed host=%s: %s: %s',
|
||||||
|
label, lane.lane_id, host, type(exc).__name__, exc)
|
||||||
|
await lane.close()
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await lane.close()
|
||||||
|
raise
|
||||||
|
if self.closed:
|
||||||
|
await lane.close()
|
||||||
|
raise ConnectionError('H2 pool closed during preflight')
|
||||||
|
self.lanes[host] = lane
|
||||||
|
self.next_channel += 1
|
||||||
|
channel = _HttpChannel(lane, self.next_channel, label)
|
||||||
|
balancer.update_domain_for_dc(dc, base_domain)
|
||||||
|
log.info('[%s] ROUTE DC%d media -> H2 lane=%d channel=%d host=%s '
|
||||||
|
'channels=%d tcp_connections=%d', label, dc, lane.lane_id,
|
||||||
|
channel.channel_id, host, len(lane.channels), lane.tcp_connections)
|
||||||
|
return channel
|
||||||
|
log.debug('[%s] H2 DC%d unavailable; using WS', label, dc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def log_stats(self) -> None:
|
||||||
|
for lane in self.lanes.values():
|
||||||
|
if lane.channels:
|
||||||
|
log.debug('H2 STATS %s', lane.summary())
|
||||||
|
|
||||||
|
def log_flow(self, now: float) -> None:
|
||||||
|
for lane in self.lanes.values():
|
||||||
|
lane.log_flow(now)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
await asyncio.gather(*(lane.close() for lane in self.lanes.values()))
|
||||||
|
self.lanes.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def bridge_h2(reader, writer, channel: _HttpChannel, ctx, tag: bytes) -> None:
|
||||||
|
async def write_native(data):
|
||||||
|
try:
|
||||||
|
writer.write(ctx.clt_enc.update(data))
|
||||||
|
await writer.drain()
|
||||||
|
except OSError as exc:
|
||||||
|
raise _NativeDeliveryError(str(exc)) from exc
|
||||||
|
|
||||||
|
async def upload() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
body, quick = await _read_packet(reader, ctx.clt_dec, tag)
|
||||||
|
except OSError as exc:
|
||||||
|
raise _NativeDeliveryError(str(exc)) from exc
|
||||||
|
await channel.send(body, quick)
|
||||||
|
|
||||||
|
async def download() -> None:
|
||||||
|
while True:
|
||||||
|
body = await channel.receive()
|
||||||
|
if channel.closed:
|
||||||
|
raise _NativeDeliveryError('H2 channel closed before native response')
|
||||||
|
channel.delivering_since = time.monotonic()
|
||||||
|
try:
|
||||||
|
await write_native(_encode_reply(body, tag))
|
||||||
|
finally:
|
||||||
|
channel.delivering_since = None
|
||||||
|
channel.down += len(body)
|
||||||
|
stats.bytes_down += len(body)
|
||||||
|
channel.last_progress = time.monotonic()
|
||||||
|
channel._wake_receiver()
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(upload()), asyncio.create_task(download()),
|
||||||
|
asyncio.create_task(channel._recover())]
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if channel.transport_error is not None:
|
||||||
|
await write_native(_encode_reply(struct.pack('<i', channel.transport_error), tag))
|
||||||
|
channel.down += 4
|
||||||
|
stats.bytes_down += 4
|
||||||
|
else:
|
||||||
|
for task in done:
|
||||||
|
task.result()
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
log.debug('[%s] H2 channel=%d native EOF', channel.label, channel.channel_id)
|
||||||
|
except _NativeDeliveryError as exc:
|
||||||
|
log.debug('[%s] H2 channel=%d native closed: %s', channel.label, channel.channel_id, exc)
|
||||||
|
except (httpx.HTTPError, OSError, ValueError, BufferError, asyncio.TimeoutError) as exc:
|
||||||
|
if not channel.closed:
|
||||||
|
log.warning('[%s] H2 channel=%d bridge ended: %s: %s',
|
||||||
|
channel.label, channel.channel_id, type(exc).__name__, exc)
|
||||||
|
finally:
|
||||||
|
await channel.close()
|
||||||
@@ -70,11 +70,17 @@ class ProxyConfig:
|
|||||||
fallback_cfproxy: bool = True
|
fallback_cfproxy: bool = True
|
||||||
cfproxy_user_domains: List[str] = field(default_factory=list)
|
cfproxy_user_domains: List[str] = field(default_factory=list)
|
||||||
cfproxy_worker_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
|
disable_secure: bool = False
|
||||||
fake_tls_domain: str = ''
|
fake_tls_domain: str = ''
|
||||||
proxy_protocol: bool = False
|
proxy_protocol: bool = False
|
||||||
force_test_dc: 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()
|
proxy_config = ProxyConfig()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_self_pipe(loop):
|
||||||
|
reader, writer = socket.socketpair()
|
||||||
|
try:
|
||||||
|
reader.setblocking(False)
|
||||||
|
writer.setblocking(False)
|
||||||
|
if threading.current_thread() is threading.main_thread():
|
||||||
|
previous_fd = signal.set_wakeup_fd(writer.fileno())
|
||||||
|
if previous_fd != loop._csock.fileno():
|
||||||
|
signal.set_wakeup_fd(previous_fd)
|
||||||
|
except BaseException:
|
||||||
|
reader.close()
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
old_reader, old_writer = loop._ssock, loop._csock
|
||||||
|
loop._ssock, loop._csock = reader, writer
|
||||||
|
old_reader.close()
|
||||||
|
old_writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _SelfPipeGuard:
|
||||||
|
"""Work around https://github.com/python/cpython/issues/156333"""
|
||||||
|
|
||||||
|
def __init__(self, loop):
|
||||||
|
self.loop = loop
|
||||||
|
self.read_self_pipe = loop._loop_self_reading
|
||||||
|
|
||||||
|
def __call__(self, future=None):
|
||||||
|
loop = self.loop
|
||||||
|
if future is not None:
|
||||||
|
if loop._self_reading_future is not future:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = future.result()
|
||||||
|
except OSError as exc:
|
||||||
|
reason = str(exc)
|
||||||
|
except BaseException:
|
||||||
|
return self.read_self_pipe(future)
|
||||||
|
else:
|
||||||
|
if data:
|
||||||
|
return self.read_self_pipe(future)
|
||||||
|
reason = 'EOF'
|
||||||
|
|
||||||
|
loop._self_reading_future = None
|
||||||
|
self._recover(reason)
|
||||||
|
return
|
||||||
|
return self.read_self_pipe(future)
|
||||||
|
|
||||||
|
def _recover(self, reason):
|
||||||
|
loop = self.loop
|
||||||
|
if loop.is_closed() or loop._self_reading_future is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_replace_self_pipe(loop)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning('Windows event loop wakeup socket recovery failed: %s; '
|
||||||
|
'retry in 1s', exc)
|
||||||
|
loop.call_later(1, self._recover, reason)
|
||||||
|
return
|
||||||
|
log.warning('Windows event loop wakeup socket lost (%s); recreated', reason)
|
||||||
|
self.read_self_pipe()
|
||||||
|
|
||||||
|
|
||||||
|
def install_self_pipe_guard(loop):
|
||||||
|
if sys.platform != 'win32' or not isinstance(loop, asyncio.ProactorEventLoop):
|
||||||
|
return
|
||||||
|
if not isinstance(loop._loop_self_reading, _SelfPipeGuard):
|
||||||
|
guard = _SelfPipeGuard(loop)
|
||||||
|
loop._loop_self_reading = guard
|
||||||
|
future = loop._self_reading_future
|
||||||
|
if future is not None:
|
||||||
|
future.remove_done_callback(guard.read_self_pipe)
|
||||||
|
future.add_done_callback(guard)
|
||||||
+233
-26
@@ -5,12 +5,19 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from typing import Optional, Tuple
|
from typing import Dict, FrozenSet, NamedTuple, Optional, Tuple
|
||||||
from .stats import stats
|
from .stats import stats
|
||||||
|
from .utils import set_x25519
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||||
|
except ImportError:
|
||||||
|
X25519PrivateKey = None
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
@@ -32,26 +39,46 @@ TIMESTAMP_TOLERANCE = 120
|
|||||||
|
|
||||||
TLS_APPDATA_MAX = 16384
|
TLS_APPDATA_MAX = 16384
|
||||||
|
|
||||||
|
EXT_SUPPORTED_VERSIONS = 0x002b
|
||||||
|
EXT_KEY_SHARE = 0x0033
|
||||||
|
|
||||||
|
GROUP_X25519 = 0x001d
|
||||||
|
GROUP_X25519MLKEM768 = 0x11ec
|
||||||
|
|
||||||
|
X25519_KEY_LEN = 32
|
||||||
|
X25519MLKEM768_CLIENT_SHARE_LEN = 1216
|
||||||
|
MLKEM768_CIPHERTEXT_LEN = 1088
|
||||||
|
|
||||||
|
# TLS 1.3 suites in the order a typical server picks them from the client list
|
||||||
|
CIPHER_PREFERENCE = (0x1301, 0x1303, 0x1302)
|
||||||
|
|
||||||
|
# Bounds for the single ApplicationData record that follows CCS; the low bound
|
||||||
|
# matches the smallest record telemt emits, the high one is the TLS limit
|
||||||
|
APPDATA_MIN = 64
|
||||||
|
# Without a site profile stay near the 64-byte record telemt passed DPI with,
|
||||||
|
# far from the 1900-2100 bytes of the classic MTProxy answer
|
||||||
|
FALLBACK_APPDATA_RANGE = (64, 160)
|
||||||
|
|
||||||
|
SERVER_HELLO_DELAY = (0.008, 0.024)
|
||||||
|
|
||||||
|
PROFILE_PROBE_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
|
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
|
||||||
|
|
||||||
_SERVER_HELLO_TEMPLATE = bytearray(
|
|
||||||
b'\x16\x03\x03\x00\x7a'
|
|
||||||
b'\x02\x00\x00\x76'
|
|
||||||
b'\x03\x03'
|
|
||||||
+ b'\x00' * 32
|
|
||||||
+ b'\x20'
|
|
||||||
+ b'\x00' * 32
|
|
||||||
+ b'\x13\x01\x00'
|
|
||||||
+ b'\x00\x2e'
|
|
||||||
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
|
|
||||||
+ b'\x00' * 32
|
|
||||||
+ b'\x00\x2b\x00\x02\x03\x04'
|
|
||||||
)
|
|
||||||
|
|
||||||
_SH_RANDOM_OFF = 11
|
_SH_RANDOM_OFF = 11
|
||||||
_SH_SESSID_OFF = 44
|
|
||||||
_SH_PUBKEY_OFF = 89
|
# Size of the first encrypted record the masking site sends after ServerHello;
|
||||||
|
# set once at startup by probe_appdata_len()
|
||||||
|
_profiled_appdata_len: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClientHelloInfo(NamedTuple):
|
||||||
|
cipher_suites: FrozenSet[int]
|
||||||
|
key_shares: Dict[int, int] # named group -> key_exchange length
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_HELLO_INFO = ClientHelloInfo(frozenset(), {})
|
||||||
|
|
||||||
|
|
||||||
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
|
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
|
||||||
@@ -88,17 +115,114 @@ def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, byt
|
|||||||
return client_random, session_id, timestamp
|
return client_random, session_id, timestamp
|
||||||
|
|
||||||
|
|
||||||
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
|
def parse_client_hello(data: bytes) -> ClientHelloInfo:
|
||||||
sh = bytearray(_SERVER_HELLO_TEMPLATE)
|
"""Extract offered cipher suites and key_share groups from a ClientHello.
|
||||||
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
|
|
||||||
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
|
|
||||||
|
|
||||||
ccs = _CCS_FRAME
|
Returns empty info on malformed input so the caller falls back to the
|
||||||
encrypted_size = random.randint(1900, 2100)
|
plain X25519 / TLS_AES_128_GCM_SHA256 answer.
|
||||||
encrypted_data = os.urandom(encrypted_size)
|
"""
|
||||||
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
|
try:
|
||||||
|
pos = 5 + 4 + 2 + CLIENT_RANDOM_LEN
|
||||||
|
pos += 1 + data[pos]
|
||||||
|
|
||||||
response = bytes(sh) + ccs + app_record
|
suites_len = struct.unpack_from('>H', data, pos)[0]
|
||||||
|
pos += 2
|
||||||
|
suites_end = pos + suites_len
|
||||||
|
if suites_end > len(data):
|
||||||
|
return _EMPTY_HELLO_INFO
|
||||||
|
suites = frozenset(
|
||||||
|
struct.unpack_from('>H', data, off)[0]
|
||||||
|
for off in range(pos, suites_end - 1, 2))
|
||||||
|
pos = suites_end
|
||||||
|
|
||||||
|
pos += 1 + data[pos]
|
||||||
|
|
||||||
|
ext_total = struct.unpack_from('>H', data, pos)[0]
|
||||||
|
pos += 2
|
||||||
|
ext_end = min(pos + ext_total, len(data))
|
||||||
|
key_shares: Dict[int, int] = {}
|
||||||
|
while pos + 4 <= ext_end:
|
||||||
|
ext_type, ext_len = struct.unpack_from('>HH', data, pos)
|
||||||
|
pos += 4
|
||||||
|
if ext_type == EXT_KEY_SHARE and pos + 2 <= ext_end:
|
||||||
|
shares_end = min(pos + 2 + struct.unpack_from('>H', data, pos)[0],
|
||||||
|
ext_end)
|
||||||
|
off = pos + 2
|
||||||
|
while off + 4 <= shares_end:
|
||||||
|
group, key_len = struct.unpack_from('>HH', data, off)
|
||||||
|
off += 4 + key_len
|
||||||
|
if off <= shares_end:
|
||||||
|
key_shares.setdefault(group, key_len)
|
||||||
|
pos += ext_len
|
||||||
|
except (IndexError, struct.error):
|
||||||
|
return _EMPTY_HELLO_INFO
|
||||||
|
|
||||||
|
return ClientHelloInfo(suites, key_shares)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_cipher_suite(info: ClientHelloInfo) -> int:
|
||||||
|
for suite in CIPHER_PREFERENCE:
|
||||||
|
if suite in info.cipher_suites:
|
||||||
|
return suite
|
||||||
|
return CIPHER_PREFERENCE[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _x25519_public_key() -> bytes:
|
||||||
|
if X25519PrivateKey is not None:
|
||||||
|
return X25519PrivateKey.generate().public_key().public_bytes_raw()
|
||||||
|
# Real public keys are field elements below 2^255; keep the top bit clear
|
||||||
|
key = bytearray(os.urandom(X25519_KEY_LEN))
|
||||||
|
key[-1] &= 0x7f
|
||||||
|
return bytes(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _server_key_share(info: ClientHelloInfo) -> Tuple[int, bytes]:
|
||||||
|
# Mirror the hybrid post-quantum group when the client offers it, as
|
||||||
|
# current browsers and servers do; the client never derives keys from it
|
||||||
|
if info.key_shares.get(GROUP_X25519MLKEM768) == X25519MLKEM768_CLIENT_SHARE_LEN:
|
||||||
|
return GROUP_X25519MLKEM768, (os.urandom(MLKEM768_CIPHERTEXT_LEN)
|
||||||
|
+ _x25519_public_key())
|
||||||
|
return GROUP_X25519, _x25519_public_key()
|
||||||
|
|
||||||
|
|
||||||
|
def _appdata_len() -> int:
|
||||||
|
if _profiled_appdata_len is not None:
|
||||||
|
return _profiled_appdata_len
|
||||||
|
return random.randint(*FALLBACK_APPDATA_RANGE)
|
||||||
|
|
||||||
|
|
||||||
|
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes,
|
||||||
|
info: ClientHelloInfo = _EMPTY_HELLO_INFO) -> bytes:
|
||||||
|
"""ServerHello + CCS + one ApplicationData record.
|
||||||
|
|
||||||
|
Telegram clients parse exactly these three records and check the HMAC
|
||||||
|
over all of them, so the shape must stay fixed while sizes may vary.
|
||||||
|
"""
|
||||||
|
group, key_exchange = _server_key_share(info)
|
||||||
|
extensions = (
|
||||||
|
struct.pack('>HHH', EXT_SUPPORTED_VERSIONS, 2, 0x0304)
|
||||||
|
+ struct.pack('>HHHH', EXT_KEY_SHARE, 4 + len(key_exchange),
|
||||||
|
group, len(key_exchange))
|
||||||
|
+ key_exchange
|
||||||
|
)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
TLS_VERSION_12
|
||||||
|
+ b'\x00' * 32
|
||||||
|
+ bytes([len(session_id)]) + session_id
|
||||||
|
+ struct.pack('>H', _select_cipher_suite(info))
|
||||||
|
+ b'\x00'
|
||||||
|
+ struct.pack('>H', len(extensions)) + extensions
|
||||||
|
)
|
||||||
|
message = b'\x02' + struct.pack('>I', len(body))[1:] + body
|
||||||
|
server_hello = (bytes([TLS_RECORD_HANDSHAKE]) + TLS_VERSION_12
|
||||||
|
+ struct.pack('>H', len(message)) + message)
|
||||||
|
|
||||||
|
appdata_len = _appdata_len()
|
||||||
|
app_record = (bytes([TLS_RECORD_APPDATA]) + TLS_VERSION_12
|
||||||
|
+ struct.pack('>H', appdata_len) + os.urandom(appdata_len))
|
||||||
|
|
||||||
|
response = server_hello + _CCS_FRAME + app_record
|
||||||
|
|
||||||
hmac_input = client_random + response
|
hmac_input = client_random + response
|
||||||
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
|
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
|
||||||
@@ -109,6 +233,89 @@ def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -
|
|||||||
return bytes(final)
|
return bytes(final)
|
||||||
|
|
||||||
|
|
||||||
|
def set_profiled_appdata_len(length: Optional[int]) -> None:
|
||||||
|
global _profiled_appdata_len
|
||||||
|
if length is None:
|
||||||
|
_profiled_appdata_len = None
|
||||||
|
else:
|
||||||
|
_profiled_appdata_len = max(APPDATA_MIN, min(length, TLS_APPDATA_MAX))
|
||||||
|
|
||||||
|
|
||||||
|
def first_appdata_record_len(data: bytes) -> Optional[int]:
|
||||||
|
"""Length of the first ApplicationData record in a server's first flight.
|
||||||
|
|
||||||
|
Returns None while more data is needed, 0 if the flight cannot contain
|
||||||
|
one (alert, TLS 1.2 handshake, garbage).
|
||||||
|
"""
|
||||||
|
pos = 0
|
||||||
|
while pos + 5 <= len(data):
|
||||||
|
rtype = data[pos]
|
||||||
|
rec_len = struct.unpack_from('>H', data, pos + 3)[0]
|
||||||
|
if rtype == TLS_RECORD_APPDATA:
|
||||||
|
return rec_len
|
||||||
|
if rtype not in (TLS_RECORD_HANDSHAKE, TLS_RECORD_CCS):
|
||||||
|
return 0
|
||||||
|
pos += 5 + rec_len
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_appdata_len(domain: str, port: int = 443,
|
||||||
|
timeout: float = PROFILE_PROBE_TIMEOUT) -> Optional[int]:
|
||||||
|
"""Measure the first encrypted record the masking site sends after ServerHello.
|
||||||
|
|
||||||
|
Only the raw first flight is read; the TLS handshake is never completed.
|
||||||
|
"""
|
||||||
|
if not getattr(ssl, 'HAS_TLSv1_3', False):
|
||||||
|
log.warning("Fake TLS profile: local ssl lacks TLS 1.3, "
|
||||||
|
"using random record sizes")
|
||||||
|
return None
|
||||||
|
|
||||||
|
writer = None
|
||||||
|
try:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
set_x25519(ctx)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.set_alpn_protocols(['h2', 'http/1.1'])
|
||||||
|
outgoing = ssl.MemoryBIO()
|
||||||
|
tls = ctx.wrap_bio(ssl.MemoryBIO(), outgoing, server_hostname=domain)
|
||||||
|
try:
|
||||||
|
tls.do_handshake()
|
||||||
|
except ssl.SSLWantReadError:
|
||||||
|
pass
|
||||||
|
client_hello = outgoing.read()
|
||||||
|
|
||||||
|
# Share one deadline across connecting, sending, and reading the flight.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + timeout
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(domain, port), timeout=timeout)
|
||||||
|
writer.write(client_hello)
|
||||||
|
await asyncio.wait_for(writer.drain(), timeout=deadline - loop.time())
|
||||||
|
|
||||||
|
flight = bytearray()
|
||||||
|
while True:
|
||||||
|
remaining = deadline - loop.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise asyncio.TimeoutError
|
||||||
|
chunk = await asyncio.wait_for(
|
||||||
|
reader.read(16384), timeout=remaining)
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
flight.extend(chunk)
|
||||||
|
length = first_appdata_record_len(bytes(flight))
|
||||||
|
if length is not None:
|
||||||
|
return length or None
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Fake TLS profile: cannot probe %s:%d: %r",
|
||||||
|
domain, port, exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if writer is not None:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
def wrap_tls_record(data: bytes) -> bytes:
|
def wrap_tls_record(data: bytes) -> bytes:
|
||||||
parts = []
|
parts = []
|
||||||
offset = 0
|
offset = 0
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from h2.config import H2Configuration
|
||||||
|
from h2.connection import H2Connection
|
||||||
|
from h2.errors import ErrorCodes
|
||||||
|
from h2.events import (
|
||||||
|
ConnectionTerminated, DataReceived, RemoteSettingsChanged,
|
||||||
|
ResponseReceived, StreamEnded, StreamReset, WindowUpdated,
|
||||||
|
)
|
||||||
|
from h2.exceptions import H2Error, StreamClosedError
|
||||||
|
from h2.settings import SettingCodes, Settings
|
||||||
|
|
||||||
|
from .utils import create_ssl_context
|
||||||
|
|
||||||
|
STREAM_RECEIVE_WINDOW = 256 * 1024
|
||||||
|
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
|
||||||
|
IO_BATCH_SIZE = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _default_ssl_context():
|
||||||
|
context = create_ssl_context()
|
||||||
|
context.set_alpn_protocols(['h2'])
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class _ResponseStream(httpx.AsyncByteStream):
|
||||||
|
def __init__(self, connection, stream_id, read_timeout):
|
||||||
|
self.connection = connection
|
||||||
|
self.stream_id = stream_id
|
||||||
|
self.read_timeout = read_timeout
|
||||||
|
self.changed = asyncio.Event()
|
||||||
|
self.chunks = deque()
|
||||||
|
self.consumed_credit = 0
|
||||||
|
self.headers = None
|
||||||
|
self.response_started = None
|
||||||
|
self.error = None
|
||||||
|
self.ended = False
|
||||||
|
self.closed = False
|
||||||
|
self.extensions = {'http_version': b'HTTP/2', 'stream_id': stream_id}
|
||||||
|
|
||||||
|
async def wait(self):
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
error = httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id)
|
||||||
|
# An idle socket can remain established after the path stops
|
||||||
|
# delivering data. Reconnect once its last active request times
|
||||||
|
# out, while preserving unrelated requests and buffered replies.
|
||||||
|
if not any(stream is not self and not stream.ended
|
||||||
|
for stream in self.connection.streams.values()):
|
||||||
|
self.connection.fail(error)
|
||||||
|
raise error from exc
|
||||||
|
|
||||||
|
def check_error(self):
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
while not self.closed:
|
||||||
|
self.changed.clear()
|
||||||
|
self.check_error()
|
||||||
|
if self.chunks:
|
||||||
|
data, credit = self.chunks.popleft()
|
||||||
|
yield data
|
||||||
|
self.connection.consumed(self, credit)
|
||||||
|
elif self.ended:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
await self.wait()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
if not self.closed:
|
||||||
|
self.closed = True
|
||||||
|
self.chunks.clear()
|
||||||
|
self.connection.release(self)
|
||||||
|
self.changed.set()
|
||||||
|
|
||||||
|
|
||||||
|
class _Connection:
|
||||||
|
def __init__(self, reader, writer, max_streams, write_timeout):
|
||||||
|
self.reader, self.writer = reader, writer
|
||||||
|
self.max_streams = max_streams
|
||||||
|
self.write_timeout = write_timeout
|
||||||
|
self.h2 = H2Connection(config=H2Configuration(client_side=True))
|
||||||
|
self.h2.local_settings = Settings(
|
||||||
|
client=True, initial_values={SettingCodes.ENABLE_PUSH: 0,
|
||||||
|
SettingCodes.INITIAL_WINDOW_SIZE: STREAM_RECEIVE_WINDOW})
|
||||||
|
self.streams = {}
|
||||||
|
self.changed = asyncio.Event()
|
||||||
|
self.write_lock = asyncio.Lock()
|
||||||
|
self.settings_received = False
|
||||||
|
self.error = None
|
||||||
|
self.h2.initiate_connection()
|
||||||
|
self.h2.increment_flow_control_window(CONNECTION_RECEIVE_WINDOW - 65535)
|
||||||
|
self.flush()
|
||||||
|
self.reader_task = asyncio.create_task(self.read_loop())
|
||||||
|
|
||||||
|
def get_extra_info(self, name):
|
||||||
|
return self.writer.get_extra_info(name)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
data = self.h2.data_to_send()
|
||||||
|
if data:
|
||||||
|
self.writer.write(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
try:
|
||||||
|
async with self.write_lock:
|
||||||
|
await asyncio.wait_for(self.writer.drain(), self.write_timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
error = httpx.WriteTimeout('H2 connection write timed out')
|
||||||
|
self.fail(error)
|
||||||
|
raise error from exc
|
||||||
|
except OSError as exc:
|
||||||
|
error = httpx.WriteError(str(exc))
|
||||||
|
self.fail(error)
|
||||||
|
raise error from exc
|
||||||
|
|
||||||
|
def fail(self, error):
|
||||||
|
if self.error is None:
|
||||||
|
self.error = error
|
||||||
|
for stream in self.streams.values():
|
||||||
|
if not stream.ended:
|
||||||
|
stream.error = error
|
||||||
|
stream.changed.set()
|
||||||
|
self.changed.set()
|
||||||
|
self.writer.close()
|
||||||
|
|
||||||
|
async def read_loop(self):
|
||||||
|
try:
|
||||||
|
while self.error is None:
|
||||||
|
data = await self.reader.read(IO_BATCH_SIZE)
|
||||||
|
if not data:
|
||||||
|
raise httpx.ReadError('H2 peer closed the connection')
|
||||||
|
events = self.h2.receive_data(data)
|
||||||
|
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
|
||||||
|
connection_credit = 0
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, RemoteSettingsChanged):
|
||||||
|
self.settings_received = True
|
||||||
|
self.changed.set()
|
||||||
|
elif isinstance(event, WindowUpdated):
|
||||||
|
self.changed.set()
|
||||||
|
elif isinstance(event, ConnectionTerminated):
|
||||||
|
raise httpx.RemoteProtocolError('H2 GOAWAY error=%s last_stream=%s' % (
|
||||||
|
event.error_code, event.last_stream_id))
|
||||||
|
else:
|
||||||
|
stream = self.streams.get(getattr(event, 'stream_id', None))
|
||||||
|
if isinstance(event, DataReceived):
|
||||||
|
if event.flow_controlled_length and not terminated:
|
||||||
|
connection_credit += event.flow_controlled_length
|
||||||
|
if stream is not None:
|
||||||
|
if event.data:
|
||||||
|
stream.chunks.append((event.data, event.flow_controlled_length))
|
||||||
|
elif not terminated:
|
||||||
|
self.consumed(stream, event.flow_controlled_length)
|
||||||
|
elif stream is not None:
|
||||||
|
if isinstance(event, ResponseReceived):
|
||||||
|
stream.headers = event.headers
|
||||||
|
if stream.response_started is not None:
|
||||||
|
stream.response_started()
|
||||||
|
elif isinstance(event, StreamEnded):
|
||||||
|
stream.ended = True
|
||||||
|
elif isinstance(event, StreamReset):
|
||||||
|
stream.error = httpx.RemoteProtocolError(
|
||||||
|
'H2 stream %d reset: %s' % (event.stream_id, event.error_code))
|
||||||
|
self.changed.set()
|
||||||
|
if stream is not None:
|
||||||
|
stream.changed.set()
|
||||||
|
if connection_credit:
|
||||||
|
self.h2.increment_flow_control_window(connection_credit)
|
||||||
|
if self.flush():
|
||||||
|
await self.drain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.fail(httpx.ReadError('H2 reader stopped'))
|
||||||
|
raise
|
||||||
|
except (OSError, H2Error, httpx.HTTPError) as exc:
|
||||||
|
error = exc if isinstance(exc, httpx.HTTPError) else httpx.RemoteProtocolError(str(exc))
|
||||||
|
self.fail(error)
|
||||||
|
|
||||||
|
def check_error(self):
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
|
||||||
|
async def reserve(self, timeouts):
|
||||||
|
timeout = timeouts.get('pool')
|
||||||
|
deadline = None if timeout is None else time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
self.changed.clear()
|
||||||
|
self.check_error()
|
||||||
|
if self.settings_received and len(self.streams) < min(
|
||||||
|
self.max_streams, self.h2.remote_settings.max_concurrent_streams):
|
||||||
|
stream_id = self.h2.get_next_available_stream_id()
|
||||||
|
stream = _ResponseStream(self, stream_id, timeouts.get('read'))
|
||||||
|
self.streams[stream_id] = stream
|
||||||
|
return stream
|
||||||
|
remaining = None if deadline is None else max(0, deadline - time.monotonic())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.changed.wait(), remaining)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise httpx.PoolTimeout('H2 stream capacity exhausted') from exc
|
||||||
|
|
||||||
|
async def send_body(self, request, stream):
|
||||||
|
async for data in request.stream:
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
self.changed.clear()
|
||||||
|
self.check_error()
|
||||||
|
stream.check_error()
|
||||||
|
count = min(len(data) - offset, IO_BATCH_SIZE,
|
||||||
|
self.h2.local_flow_control_window(stream.stream_id))
|
||||||
|
if count <= 0:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.changed.wait(), self.write_timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
|
||||||
|
continue
|
||||||
|
batch_end = offset + count
|
||||||
|
while offset < batch_end:
|
||||||
|
frame_end = min(batch_end, offset + self.h2.max_outbound_frame_size)
|
||||||
|
self.h2.send_data(stream.stream_id, data[offset:frame_end])
|
||||||
|
offset = frame_end
|
||||||
|
self.flush()
|
||||||
|
await self.drain()
|
||||||
|
self.h2.end_stream(stream.stream_id)
|
||||||
|
self.flush()
|
||||||
|
await self.drain()
|
||||||
|
|
||||||
|
async def request(self, request):
|
||||||
|
timeouts = request.extensions.get('timeout', {})
|
||||||
|
trace = request.extensions.get('trace')
|
||||||
|
if trace:
|
||||||
|
await trace('http2.wait_for_stream.started', {})
|
||||||
|
stream = await self.reserve(timeouts)
|
||||||
|
stream.response_started = request.extensions.get('h2_response_started')
|
||||||
|
stream_id = stream.stream_id
|
||||||
|
try:
|
||||||
|
headers = [(b':method', request.method.encode('ascii')),
|
||||||
|
(b':scheme', request.url.raw_scheme),
|
||||||
|
(b':authority', request.headers['host'].encode('ascii')),
|
||||||
|
(b':path', request.url.raw_path)]
|
||||||
|
headers.extend((k.lower(), v) for k, v in request.headers.raw if k.lower() not in (
|
||||||
|
b'host', b'connection', b'keep-alive', b'proxy-connection',
|
||||||
|
b'upgrade', b'transfer-encoding'))
|
||||||
|
self.h2.send_headers(stream_id, headers)
|
||||||
|
self.flush()
|
||||||
|
if trace:
|
||||||
|
await trace('http2.send_request_headers.started', {'stream_id': stream_id})
|
||||||
|
await self.send_body(request, stream)
|
||||||
|
if trace:
|
||||||
|
await trace('http2.send_request_body.complete', {'stream_id': stream_id})
|
||||||
|
while stream.headers is None:
|
||||||
|
stream.changed.clear()
|
||||||
|
stream.check_error()
|
||||||
|
await stream.wait()
|
||||||
|
stream.check_error()
|
||||||
|
status = next(int(v) for k, v in stream.headers if k == b':status')
|
||||||
|
return httpx.Response(status, headers=[(k, v) for k, v in stream.headers
|
||||||
|
if not k.startswith(b':')],
|
||||||
|
stream=stream, extensions=stream.extensions)
|
||||||
|
except H2Error as exc:
|
||||||
|
await stream.aclose()
|
||||||
|
raise httpx.LocalProtocolError(str(exc)) from exc
|
||||||
|
except BaseException:
|
||||||
|
await stream.aclose()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def consumed(self, stream, credit):
|
||||||
|
if credit and not stream.ended and not stream.closed and self.error is None:
|
||||||
|
stream.consumed_credit += credit
|
||||||
|
if stream.chunks and stream.consumed_credit < IO_BATCH_SIZE:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.h2.increment_flow_control_window(stream.consumed_credit, stream.stream_id)
|
||||||
|
stream.consumed_credit = 0
|
||||||
|
self.flush()
|
||||||
|
except StreamClosedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def release(self, stream):
|
||||||
|
self.streams.pop(stream.stream_id, None)
|
||||||
|
if self.error is None:
|
||||||
|
try:
|
||||||
|
self.h2.reset_stream(stream.stream_id, ErrorCodes.CANCEL)
|
||||||
|
self.flush()
|
||||||
|
except StreamClosedError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
self.fail(httpx.WriteError(str(exc)))
|
||||||
|
self.changed.set()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
self.fail(httpx.ReadError('H2 connection closed'))
|
||||||
|
self.reader_task.cancel()
|
||||||
|
await asyncio.gather(self.reader_task, return_exceptions=True)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.writer.wait_closed(), 2)
|
||||||
|
except (OSError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class H2Transport(httpx.AsyncBaseTransport):
|
||||||
|
def __init__(self, ssl_context=None, max_streams=64):
|
||||||
|
if ssl_context is None:
|
||||||
|
self.ssl_context = _default_ssl_context()
|
||||||
|
else:
|
||||||
|
self.ssl_context = ssl_context
|
||||||
|
self.ssl_context.set_alpn_protocols(['h2'])
|
||||||
|
self.max_streams = max_streams
|
||||||
|
self.connection = None
|
||||||
|
self.origin = None
|
||||||
|
self.connect_lock = asyncio.Lock()
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def handle_async_request(self, request):
|
||||||
|
origin = (request.url.host, request.url.port or 443)
|
||||||
|
if request.url.scheme != 'https' or self.origin not in (None, origin):
|
||||||
|
raise httpx.UnsupportedProtocol('CF H2 transport requires a single HTTPS origin')
|
||||||
|
async with self.connect_lock:
|
||||||
|
if self.closed:
|
||||||
|
raise httpx.ConnectError('H2 transport closed')
|
||||||
|
self.origin = origin
|
||||||
|
if self.connection is None or self.connection.error is not None:
|
||||||
|
if self.connection is not None:
|
||||||
|
await self.connection.aclose()
|
||||||
|
timeouts = request.extensions.get('timeout', {})
|
||||||
|
trace = request.extensions.get('trace')
|
||||||
|
if trace:
|
||||||
|
await trace('connection.connect_tcp.started', {})
|
||||||
|
writer = None
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(
|
||||||
|
*origin, ssl=self.ssl_context, server_hostname=origin[0]),
|
||||||
|
timeouts.get('connect'))
|
||||||
|
if writer.get_extra_info('ssl_object').selected_alpn_protocol() != 'h2':
|
||||||
|
raise httpx.RemoteProtocolError('CF origin did not negotiate h2')
|
||||||
|
self.connection = _Connection(reader, writer, self.max_streams, timeouts.get('write'))
|
||||||
|
if trace:
|
||||||
|
await trace('connection.start_tls.complete', {'return_value': self.connection})
|
||||||
|
except BaseException as exc:
|
||||||
|
if self.connection is not None:
|
||||||
|
self.connection.fail(httpx.ConnectError('H2 connection setup interrupted'))
|
||||||
|
if writer is not None:
|
||||||
|
writer.close()
|
||||||
|
if isinstance(exc, asyncio.TimeoutError):
|
||||||
|
raise httpx.ConnectTimeout('H2 TLS connect timed out') from exc
|
||||||
|
if isinstance(exc, OSError):
|
||||||
|
raise httpx.ConnectError(str(exc)) from exc
|
||||||
|
raise
|
||||||
|
connection = self.connection
|
||||||
|
return await connection.request(request)
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
self.closed = True
|
||||||
|
async with self.connect_lock:
|
||||||
|
if self.connection is not None:
|
||||||
|
await self.connection.aclose()
|
||||||
@@ -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]))
|
||||||
+119
-88
@@ -5,17 +5,16 @@ import time
|
|||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from typing import Dict, List, Optional, Tuple, Set
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from .raw_websocket import RawWebSocket, WsHandshakeError
|
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||||
from .stats import stats
|
from .stats import stats
|
||||||
from .config import proxy_config
|
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')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
# TODO: domains handling is broken: wrong is_media flag causes tcp_reset after handshake,
|
|
||||||
# but initial connection is still established no matter what is is_media flag is set to
|
|
||||||
class _WsPool:
|
class _WsPool:
|
||||||
WS_POOL_MAX_AGE = 120.0
|
WS_POOL_MAX_AGE = 120.0
|
||||||
WS_POOL_CHECK_INTERVAL = 5.0
|
WS_POOL_CHECK_INTERVAL = 5.0
|
||||||
@@ -23,17 +22,20 @@ class _WsPool:
|
|||||||
REFILL_BACKOFF_MAX = 3600.0
|
REFILL_BACKOFF_MAX = 3600.0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[Tuple[int, bool], deque] = {}
|
self._idle: Dict[Tuple[int, bool, bool], deque] = {}
|
||||||
self._refilling: Set[Tuple[int, bool]] = set()
|
self._refilling: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
|
||||||
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
|
self._rotating: Dict[Tuple[int, bool, bool], asyncio.Task] = {}
|
||||||
self._refill_failures: Dict[Tuple[int, bool], int] = {}
|
self._refill_failures: Dict[Tuple[int, bool, bool], int] = {}
|
||||||
self._refill_after: Dict[Tuple[int, bool], float] = {}
|
self._refill_after: Dict[Tuple[int, bool, bool], float] = {}
|
||||||
self.try_fronting_first = True
|
self.try_fronting_first = False
|
||||||
|
|
||||||
async def get(self, dc: int, is_media: bool,
|
async def get(self, dc: int, is_media: bool, *, is_test_dc: bool = False
|
||||||
target_ip: str, domains: List[str]
|
|
||||||
) -> Optional[RawWebSocket]:
|
) -> 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()
|
now = time.monotonic()
|
||||||
|
|
||||||
bucket = self._idle.get(key)
|
bucket = self._idle.get(key)
|
||||||
@@ -43,14 +45,12 @@ class _WsPool:
|
|||||||
while bucket:
|
while bucket:
|
||||||
ws, created = bucket.popleft()
|
ws, created = bucket.popleft()
|
||||||
age = now - created
|
age = now - created
|
||||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
if self._is_stale(ws, created, now):
|
||||||
or ws.writer.transport.is_closing()):
|
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
continue
|
continue
|
||||||
stats.pool_hits += 1
|
stats.pool_hits += 1
|
||||||
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
log.debug("WS pool hit DC%d%s%s (age=%.1fs, left=%d)",
|
||||||
dc, 'm' if is_media else '', age, len(bucket))
|
dc, 't' if is_test_dc else '', 'm' if is_media else '', age, len(bucket))
|
||||||
self.report_success(dc, is_media)
|
|
||||||
self._schedule_refill(key, target_ip, domains)
|
self._schedule_refill(key, target_ip, domains)
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
@@ -58,20 +58,25 @@ class _WsPool:
|
|||||||
self._schedule_refill(key, target_ip, domains)
|
self._schedule_refill(key, target_ip, domains)
|
||||||
return None
|
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):
|
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
|
if (key in self._refilling
|
||||||
or time.monotonic() < self._refill_after.get(key, 0)):
|
or time.monotonic() < self._refill_after.get(key, 0)):
|
||||||
return
|
return
|
||||||
self._refilling.add(key)
|
self._refilling[key] = asyncio.create_task(
|
||||||
asyncio.create_task(self._refill(key, target_ip, domains))
|
self._refill(key, target_ip, domains))
|
||||||
|
|
||||||
def report_success(self, dc: int, is_media: bool) -> None:
|
|
||||||
key = (dc, is_media)
|
|
||||||
self._refill_failures.pop(key, None)
|
|
||||||
self._refill_after.pop(key, None)
|
|
||||||
|
|
||||||
async def _refill(self, key, target_ip, domains):
|
async def _refill(self, key, target_ip, domains):
|
||||||
dc, is_media = key
|
dc, is_media, is_test_dc = key
|
||||||
|
tasks = []
|
||||||
|
adopted = set()
|
||||||
try:
|
try:
|
||||||
bucket = self._idle.setdefault(key, deque())
|
bucket = self._idle.setdefault(key, deque())
|
||||||
needed = proxy_config.pool_size - len(bucket)
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
@@ -79,19 +84,23 @@ class _WsPool:
|
|||||||
return
|
return
|
||||||
connected = 0
|
connected = 0
|
||||||
tasks = [asyncio.create_task(
|
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 _ in range(needed)]
|
||||||
for t in tasks:
|
for t in asyncio.as_completed(tasks):
|
||||||
try:
|
try:
|
||||||
ws = await t
|
ws = await t
|
||||||
if ws:
|
if ws:
|
||||||
|
if self._refilling.get(key) is not asyncio.current_task():
|
||||||
|
return
|
||||||
bucket.append((ws, time.monotonic()))
|
bucket.append((ws, time.monotonic()))
|
||||||
|
adopted.add(ws)
|
||||||
connected += 1
|
connected += 1
|
||||||
self._schedule_rotation(key, target_ip, domains)
|
except Exception as exc:
|
||||||
except Exception:
|
log.debug("WS pool connect failed: %r", exc)
|
||||||
pass
|
|
||||||
if connected:
|
if connected:
|
||||||
self.report_success(dc, is_media)
|
self._refill_failures.pop(key, None)
|
||||||
|
self._refill_after.pop(key, None)
|
||||||
else:
|
else:
|
||||||
failures = self._refill_failures.get(key, 0) + 1
|
failures = self._refill_failures.get(key, 0) + 1
|
||||||
self._refill_failures[key] = failures
|
self._refill_failures[key] = failures
|
||||||
@@ -102,12 +111,20 @@ class _WsPool:
|
|||||||
)
|
)
|
||||||
self._refill_after[key] = time.monotonic() + delay
|
self._refill_after[key] = time.monotonic() + delay
|
||||||
log.info(
|
log.info(
|
||||||
"WS pool refill failed for DC%d%s, retry in %.0fs",
|
"WS pool refill failed for DC%d%s%s, retry in %.0fs",
|
||||||
dc, 'm' if is_media else '', delay)
|
dc, 't' if is_test_dc else '', 'm' if is_media else '', delay)
|
||||||
log.debug("WS pool refilled DC%d%s: %d ready",
|
log.debug("WS pool refilled DC%d%s%s: %d ready",
|
||||||
dc, 'm' if is_media else '', len(bucket))
|
dc, 't' if is_test_dc else '', 'm' if is_media else '', len(bucket))
|
||||||
finally:
|
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):
|
def _schedule_rotation(self, key, target_ip, domains):
|
||||||
if key in self._rotating:
|
if key in self._rotating:
|
||||||
@@ -116,28 +133,16 @@ class _WsPool:
|
|||||||
self._rotate(key, target_ip, domains))
|
self._rotate(key, target_ip, domains))
|
||||||
|
|
||||||
async def _rotate(self, key, target_ip, domains):
|
async def _rotate(self, key, target_ip, domains):
|
||||||
dc, is_media = key
|
dc, is_media, is_test_dc = key
|
||||||
try:
|
try:
|
||||||
while True:
|
while proxy_config.pool_size > 0:
|
||||||
bucket = self._idle.get(key)
|
bucket = self._idle.setdefault(key, deque())
|
||||||
if not bucket:
|
|
||||||
return
|
|
||||||
|
|
||||||
expires_at = min(
|
|
||||||
created + self.WS_POOL_MAX_AGE
|
|
||||||
for _, created in bucket)
|
|
||||||
await asyncio.sleep(min(
|
|
||||||
self.WS_POOL_CHECK_INTERVAL,
|
|
||||||
max(0, expires_at - time.monotonic())))
|
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
expired = []
|
expired = []
|
||||||
ready = deque()
|
ready = deque()
|
||||||
while bucket:
|
while bucket:
|
||||||
ws, created = bucket.popleft()
|
ws, created = bucket.popleft()
|
||||||
if (now - created >= self.WS_POOL_MAX_AGE
|
if self._is_stale(ws, created, now):
|
||||||
or ws._closed
|
|
||||||
or ws.writer.transport.is_closing()):
|
|
||||||
expired.append(ws)
|
expired.append(ws)
|
||||||
else:
|
else:
|
||||||
ready.append((ws, created))
|
ready.append((ws, created))
|
||||||
@@ -147,48 +152,44 @@ class _WsPool:
|
|||||||
for ws in expired:
|
for ws in expired:
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
log.debug(
|
log.debug(
|
||||||
"WS pool rotated DC%d%s: %d stale, %d ready",
|
"WS pool rotated DC%d%s%s: %d stale, %d ready",
|
||||||
dc, 'm' if is_media else '', len(expired), len(bucket))
|
dc, 't' if is_test_dc else '', 'm' if is_media else '',
|
||||||
|
len(expired), len(bucket))
|
||||||
|
|
||||||
if len(bucket) < proxy_config.pool_size:
|
if len(bucket) < proxy_config.pool_size:
|
||||||
self._schedule_refill(key, target_ip, domains)
|
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:
|
finally:
|
||||||
if self._rotating.get(key) is asyncio.current_task():
|
if self._rotating.get(key) is asyncio.current_task():
|
||||||
self._rotating.pop(key, None)
|
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:
|
for domain in domains:
|
||||||
if self.try_fronting_first:
|
modes = (True, False) if self.try_fronting_first else (False, True)
|
||||||
ws = await self._connect_fronted(target_ip, domain)
|
for fronted in modes:
|
||||||
if ws:
|
|
||||||
return ws
|
|
||||||
try:
|
try:
|
||||||
ws = await RawWebSocket.connect(
|
ws = await RawWebSocket.connect(
|
||||||
target_ip, domain, timeout=8)
|
target_ip, domain, timeout=7 if fronted else 8, path=path,
|
||||||
self.try_fronting_first = False
|
sni="sprinthost.ru" if fronted else None)
|
||||||
return ws
|
except Exception as exc:
|
||||||
except (asyncio.TimeoutError, ConnectionResetError):
|
stats.ws_errors += 1
|
||||||
if self.try_fronting_first:
|
log.debug("WS pool connect %s%s via %s (fronting=%s): %r",
|
||||||
return None
|
domain, path, target_ip, fronted, exc)
|
||||||
return await self._connect_fronted(target_ip, domain)
|
|
||||||
except WsHandshakeError as exc:
|
|
||||||
if exc.is_redirect:
|
|
||||||
continue
|
continue
|
||||||
return None
|
self.try_fronting_first = fronted
|
||||||
except Exception:
|
if fronted:
|
||||||
return None
|
|
||||||
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
|
stats.connections_fronting += 1
|
||||||
self.try_fronting_first = True
|
log.debug("WS pool connected %s%s via %s (fronting=%s)",
|
||||||
|
domain, path, target_ip, fronted)
|
||||||
return ws
|
return ws
|
||||||
|
return None
|
||||||
|
|
||||||
async def _quiet_close(self, ws):
|
async def _quiet_close(self, ws):
|
||||||
try:
|
try:
|
||||||
@@ -202,14 +203,21 @@ class _WsPool:
|
|||||||
continue
|
continue
|
||||||
for is_media in (False, True):
|
for is_media in (False, True):
|
||||||
domains = ws_domains(dc, is_media)
|
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))
|
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
loop = asyncio.get_running_loop()
|
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:
|
if not task.done() and task.get_loop() is loop:
|
||||||
task.cancel()
|
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._idle.clear()
|
||||||
self._refilling.clear()
|
self._refilling.clear()
|
||||||
self._rotating.clear()
|
self._rotating.clear()
|
||||||
@@ -217,6 +225,11 @@ class _WsPool:
|
|||||||
self._refill_after.clear()
|
self._refill_after.clear()
|
||||||
self.try_fronting_first = False
|
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:
|
class _CfWorkerPool:
|
||||||
WS_POOL_MAX_AGE = 100.0
|
WS_POOL_MAX_AGE = 100.0
|
||||||
@@ -224,7 +237,7 @@ class _CfWorkerPool:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._idle: Dict[int, deque] = {}
|
self._idle: Dict[int, deque] = {}
|
||||||
self._refilling: Set[int] = set()
|
self._refilling: Dict[int, asyncio.Task] = {}
|
||||||
self._exhausted_until: Dict[str, float] = {}
|
self._exhausted_until: Dict[str, float] = {}
|
||||||
|
|
||||||
async def get(self, dc: int, fallback_dst: str,
|
async def get(self, dc: int, fallback_dst: str,
|
||||||
@@ -256,8 +269,7 @@ class _CfWorkerPool:
|
|||||||
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
||||||
if dc in self._refilling:
|
if dc in self._refilling:
|
||||||
return
|
return
|
||||||
self._refilling.add(dc)
|
self._refilling[dc] = asyncio.create_task(self._refill(
|
||||||
asyncio.create_task(self._refill(
|
|
||||||
dc, fallback_dst, list(worker_domains)))
|
dc, fallback_dst, list(worker_domains)))
|
||||||
|
|
||||||
async def _refill(self, dc, fallback_dst, worker_domains):
|
async def _refill(self, dc, fallback_dst, worker_domains):
|
||||||
@@ -274,11 +286,15 @@ class _CfWorkerPool:
|
|||||||
if connected is None:
|
if connected is None:
|
||||||
break
|
break
|
||||||
ws, worker_domain = connected
|
ws, worker_domain = connected
|
||||||
|
if self._refilling.get(dc) is not asyncio.current_task():
|
||||||
|
await self._quiet_close(ws)
|
||||||
|
return
|
||||||
bucket.append((ws, time.monotonic(), worker_domain))
|
bucket.append((ws, time.monotonic(), worker_domain))
|
||||||
log.debug("CF worker pool refilled DC%d: %d ready",
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
dc, len(bucket))
|
dc, len(bucket))
|
||||||
finally:
|
finally:
|
||||||
self._refilling.discard(dc)
|
if self._refilling.get(dc) is asyncio.current_task():
|
||||||
|
self._refilling.pop(dc, None)
|
||||||
|
|
||||||
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
||||||
query = urlencode({
|
query = urlencode({
|
||||||
@@ -346,10 +362,25 @@ class _CfWorkerPool:
|
|||||||
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for task in self._refilling.values():
|
||||||
|
if not task.done() and task.get_loop() is loop:
|
||||||
|
task.cancel()
|
||||||
|
for bucket in self._idle.values():
|
||||||
|
for ws, _, _ in bucket:
|
||||||
|
try:
|
||||||
|
ws.writer.close()
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("CF worker pool close failed: %r", exc)
|
||||||
self._idle.clear()
|
self._idle.clear()
|
||||||
self._refilling.clear()
|
self._refilling.clear()
|
||||||
self._exhausted_until.clear()
|
self._exhausted_until.clear()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
tasks = list(self._refilling.values())
|
||||||
|
self.reset()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
ws_pool = _WsPool()
|
ws_pool = _WsPool()
|
||||||
cf_worker_pool = _CfWorkerPool()
|
cf_worker_pool = _CfWorkerPool()
|
||||||
+16
-13
@@ -1,6 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import ssl
|
|
||||||
import certifi
|
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
import struct
|
import struct
|
||||||
@@ -9,6 +7,7 @@ import socket as _socket
|
|||||||
|
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from .config import proxy_config
|
from .config import proxy_config
|
||||||
|
from .utils import create_ssl_context
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
@@ -22,9 +21,10 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
|
|||||||
_st_H = struct.Struct('>H')
|
_st_H = struct.Struct('>H')
|
||||||
_st_Q = struct.Struct('>Q')
|
_st_Q = struct.Struct('>Q')
|
||||||
|
|
||||||
_ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
_XOR_TABLES = tuple(bytes(value ^ key for value in range(256)) for key in range(256))
|
||||||
_ssl_ctx_fronting = ssl.create_default_context(cafile=certifi.where())
|
|
||||||
_ssl_ctx_fronting.check_hostname = False
|
_ssl_ctx = create_ssl_context()
|
||||||
|
_ssl_ctx_fronting = create_ssl_context(check_hostname=False)
|
||||||
|
|
||||||
class WsHandshakeError(Exception):
|
class WsHandshakeError(Exception):
|
||||||
def __init__(self, status_code: int, status_line: str,
|
def __init__(self, status_code: int, status_line: str,
|
||||||
@@ -44,6 +44,11 @@ def _xor_mask(data: bytes, mask: bytes) -> bytes:
|
|||||||
if not data:
|
if not data:
|
||||||
return data
|
return data
|
||||||
n = len(data)
|
n = len(data)
|
||||||
|
if n >= 512:
|
||||||
|
masked = bytearray(data)
|
||||||
|
for offset in range(4):
|
||||||
|
masked[offset::4] = masked[offset::4].translate(_XOR_TABLES[mask[offset]])
|
||||||
|
return bytes(masked)
|
||||||
mask_rep = (mask * (n // 4 + 1))[:n]
|
mask_rep = (mask * (n // 4 + 1))[:n]
|
||||||
return (int.from_bytes(data, 'big') ^
|
return (int.from_bytes(data, 'big') ^
|
||||||
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
||||||
@@ -88,7 +93,7 @@ class RawWebSocket:
|
|||||||
async def connect(host: str, domain: str, timeout: float = 10.0,
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
path: str = '/apiws', *,
|
path: str = '/apiws', *,
|
||||||
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
|
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
|
||||||
ssl = _ssl_ctx_fronting if sni else _ssl_ctx
|
ssl_context = _ssl_ctx_fronting if sni else _ssl_ctx
|
||||||
|
|
||||||
if sni is None:
|
if sni is None:
|
||||||
sni = domain
|
sni = domain
|
||||||
@@ -97,7 +102,7 @@ class RawWebSocket:
|
|||||||
(
|
(
|
||||||
asyncio.open_connection(
|
asyncio.open_connection(
|
||||||
host, 443,
|
host, 443,
|
||||||
ssl=ssl,
|
ssl=ssl_context,
|
||||||
server_hostname=sni,
|
server_hostname=sni,
|
||||||
)
|
)
|
||||||
if secure
|
if secure
|
||||||
@@ -106,6 +111,7 @@ class RawWebSocket:
|
|||||||
timeout=min(timeout, 10),
|
timeout=min(timeout, 10),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||||
|
|
||||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
@@ -125,7 +131,6 @@ class RawWebSocket:
|
|||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
response_lines: list[str] = []
|
response_lines: list[str] = []
|
||||||
try:
|
|
||||||
while True:
|
while True:
|
||||||
line = await asyncio.wait_for(reader.readline(),
|
line = await asyncio.wait_for(reader.readline(),
|
||||||
timeout=timeout)
|
timeout=timeout)
|
||||||
@@ -133,12 +138,8 @@ class RawWebSocket:
|
|||||||
break
|
break
|
||||||
response_lines.append(
|
response_lines.append(
|
||||||
line.decode('utf-8', errors='replace').strip())
|
line.decode('utf-8', errors='replace').strip())
|
||||||
except asyncio.TimeoutError:
|
|
||||||
writer.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
if not response_lines:
|
if not response_lines:
|
||||||
writer.close()
|
|
||||||
raise WsHandshakeError(0, 'empty response')
|
raise WsHandshakeError(0, 'empty response')
|
||||||
|
|
||||||
first_line = response_lines[0]
|
first_line = response_lines[0]
|
||||||
@@ -157,9 +158,11 @@ class RawWebSocket:
|
|||||||
k, v = hl.split(':', 1)
|
k, v = hl.split(':', 1)
|
||||||
headers[k.strip().lower()] = v.strip()
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
|
||||||
writer.close()
|
|
||||||
raise WsHandshakeError(status_code, first_line, headers,
|
raise WsHandshakeError(status_code, first_line, headers,
|
||||||
location=headers.get('location'))
|
location=headers.get('location'))
|
||||||
|
except BaseException:
|
||||||
|
writer.close()
|
||||||
|
raise
|
||||||
|
|
||||||
async def send(self, data: bytes):
|
async def send(self, data: bytes):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ class _Stats:
|
|||||||
self.connections_total = 0
|
self.connections_total = 0
|
||||||
self.connections_active = 0
|
self.connections_active = 0
|
||||||
self.connections_ws = 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_tcp_fallback = 0
|
||||||
self.connections_cfproxy = 0
|
self.connections_cfproxy = 0
|
||||||
self.connections_fronting = 0
|
self.connections_fronting = 0
|
||||||
@@ -28,6 +32,10 @@ class _Stats:
|
|||||||
return (f"total={self.connections_total} "
|
return (f"total={self.connections_total} "
|
||||||
f"active={self.connections_active} "
|
f"active={self.connections_active} "
|
||||||
f"ws={self.connections_ws} "
|
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"tcp_fb={self.connections_tcp_fallback} "
|
||||||
f"cf={self.connections_cfproxy} "
|
f"cf={self.connections_cfproxy} "
|
||||||
f"front={self.connections_fronting} "
|
f"front={self.connections_fronting} "
|
||||||
|
|||||||
+93
-147
@@ -3,15 +3,15 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
import struct
|
import struct
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
|
||||||
import socket as _socket
|
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__ == ''):
|
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
||||||
@@ -23,24 +23,26 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
|||||||
from .utils import *
|
from .utils import *
|
||||||
from .stats import stats
|
from .stats import stats
|
||||||
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, coerce_domain_list
|
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 .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt, reset_tcp_backoff
|
||||||
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
|
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 .fake_tls import (
|
||||||
|
proxy_to_masking_domain, verify_client_hello, parse_client_hello, build_server_hello,
|
||||||
|
probe_appdata_len, set_profiled_appdata_len, FakeTlsStream, TLS_RECORD_HANDSHAKE,
|
||||||
|
SERVER_HELLO_DELAY,
|
||||||
|
)
|
||||||
from .balancer import balancer
|
from .balancer import balancer
|
||||||
from .pool import ws_pool, cf_worker_pool
|
from .pool import ws_pool, cf_worker_pool
|
||||||
|
from .cf_h2 import CfH2Pool
|
||||||
|
from .network_debug import log_ws_flow
|
||||||
|
from .event_loop import install_self_pipe_guard
|
||||||
from ._aes import Cipher, algorithms, modes
|
from ._aes import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
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_CHECK_INTERVAL = 5.0
|
||||||
LISTENER_RESTART_DELAY = 1.0
|
LISTENER_RESTART_DELAY = 1.0
|
||||||
ws_blacklist: Set[str] = set()
|
cf_h2_pool: Optional[CfH2Pool] = None
|
||||||
dc_fail_until: Dict[str, float] = {}
|
|
||||||
ip_fail_until: Dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
||||||
@@ -165,7 +167,9 @@ async def _read_client_init(reader, writer, secret, label, masking):
|
|||||||
client_random, session_id, ts = tls_result
|
client_random, session_id, ts = tls_result
|
||||||
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
|
log.debug("[%s] Fake TLS handshake ok (ts=%d)", label, ts)
|
||||||
|
|
||||||
server_hello = build_server_hello(secret, client_random, session_id)
|
server_hello = build_server_hello(
|
||||||
|
secret, client_random, session_id, parse_client_hello(client_hello))
|
||||||
|
await asyncio.sleep(random.uniform(*SERVER_HELLO_DELAY))
|
||||||
writer.write(server_hello)
|
writer.write(server_hello)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
@@ -296,140 +300,32 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
relay_init = _generate_relay_init(proto_tag, dc_idx)
|
relay_init = _generate_relay_init(proto_tag, dc_idx)
|
||||||
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
|
ctx = _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init)
|
||||||
|
|
||||||
dc_key = f'{dc}{"t" if is_test_dc else ""}{"m" if is_media else ""}'
|
|
||||||
media_tag = " media" if is_media else ""
|
media_tag = " media" if is_media else ""
|
||||||
now = time.monotonic()
|
ws = await ws_pool.get(dc, is_media, is_test_dc=is_test_dc)
|
||||||
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
|
|
||||||
target = proxy_config.dc_redirects.get(dc)
|
|
||||||
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
|
|
||||||
domains = ws_domains(dc, is_media)
|
|
||||||
ws = None
|
|
||||||
|
|
||||||
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
|
|
||||||
if (dc not in proxy_config.dc_redirects
|
|
||||||
or dc_key in ws_blacklist
|
|
||||||
or now < ip_fail_until.get(target, 0) and is_any_cf_fallback):
|
|
||||||
|
|
||||||
if dc not in proxy_config.dc_redirects:
|
|
||||||
log.info("[%s] DC%d not in config -> fallback",
|
|
||||||
label, dc)
|
|
||||||
elif dc_key in ws_blacklist:
|
|
||||||
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
|
||||||
label, dc, media_tag)
|
|
||||||
else:
|
|
||||||
# Try to get WS from pool first, might be accidental timeout
|
|
||||||
ws = await ws_pool.get(
|
|
||||||
dc, is_media, target, domains
|
|
||||||
) if not is_test_dc else None
|
|
||||||
|
|
||||||
if not ws:
|
|
||||||
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
|
|
||||||
label, dc, media_tag, target)
|
|
||||||
else:
|
|
||||||
log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
|
|
||||||
label, dc, media_tag, target)
|
|
||||||
|
|
||||||
if not ws:
|
|
||||||
splitter = None
|
|
||||||
try:
|
|
||||||
splitter = MsgSplitter(relay_init, proto_int)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
ok = await do_fallback(
|
|
||||||
clt_reader, clt_writer, relay_init, label,
|
|
||||||
dc, is_test_dc, is_media, media_tag,
|
|
||||||
ctx, splitter=splitter)
|
|
||||||
if not ok:
|
|
||||||
log.warning("[%s] DC%d%s no fallback available",
|
|
||||||
label, dc, media_tag)
|
|
||||||
return
|
|
||||||
|
|
||||||
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
|
|
||||||
ws_failed_redirect = False
|
|
||||||
ws_timed_out = False
|
|
||||||
all_redirects = True
|
|
||||||
|
|
||||||
ws = ws or await ws_pool.get(
|
|
||||||
dc, is_media, target, domains
|
|
||||||
) if not is_test_dc else None
|
|
||||||
if ws:
|
|
||||||
log.info("[%s] DC%d%s -> pool hit via %s",
|
|
||||||
label, dc, media_tag, target)
|
|
||||||
else:
|
|
||||||
for domain in domains:
|
|
||||||
url = f'wss://{domain}{ws_path}'
|
|
||||||
log.info("[%s] DC%d%s -> %s via %s",
|
|
||||||
label, dc, media_tag, url, target)
|
|
||||||
try:
|
|
||||||
ws = await RawWebSocket.connect(target, domain,
|
|
||||||
timeout=ws_timeout,
|
|
||||||
path=ws_path)
|
|
||||||
all_redirects = False
|
|
||||||
break
|
|
||||||
except WsHandshakeError as exc:
|
|
||||||
stats.ws_errors += 1
|
|
||||||
if exc.is_redirect:
|
|
||||||
ws_failed_redirect = True
|
|
||||||
log.warning("[%s] DC%d%s got %d from %s -> %s",
|
|
||||||
label, dc, media_tag,
|
|
||||||
exc.status_code, domain,
|
|
||||||
exc.location or '?')
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
all_redirects = False
|
|
||||||
log.warning("[%s] DC%d%s WS handshake: %s",
|
|
||||||
label, dc, media_tag, exc.status_line)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
stats.ws_errors += 1
|
|
||||||
ws_timed_out = True
|
|
||||||
log.warning("[%s] DC%d%s WS connect timed out via %s",
|
|
||||||
label, dc, media_tag, domain)
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
stats.ws_errors += 1
|
|
||||||
all_redirects = False
|
|
||||||
log.warning("[%s] DC%d%s WS connect failed: %s",
|
|
||||||
label, dc, media_tag, repr(exc))
|
|
||||||
|
|
||||||
# WS failed -> fallback
|
|
||||||
if ws is None:
|
if ws is None:
|
||||||
if ws_timed_out:
|
log.info("[%s] DC%d%s WS pool unavailable -> fallback",
|
||||||
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)
|
label, dc, media_tag)
|
||||||
elif ws_failed_redirect:
|
|
||||||
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
|
||||||
else:
|
|
||||||
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
|
|
||||||
log.info("[%s] DC%d%s WS failed for %ds",
|
|
||||||
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
|
|
||||||
|
|
||||||
splitter_fb = None
|
splitter_fb = None
|
||||||
try:
|
try:
|
||||||
splitter_fb = MsgSplitter(relay_init, proto_int)
|
splitter_fb = MsgSplitter(proto_int)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ok = await do_fallback(
|
ok = await do_fallback(
|
||||||
clt_reader, clt_writer, relay_init, label,
|
clt_reader, clt_writer, relay_init, label,
|
||||||
dc, is_test_dc, is_media, media_tag,
|
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:
|
if ok:
|
||||||
log.info("[%s] DC%d%s fallback closed",
|
log.info("[%s] DC%d%s fallback closed", label, dc, media_tag)
|
||||||
label, dc, media_tag)
|
else:
|
||||||
|
log.warning("[%s] DC%d%s no fallback available", label, dc, media_tag)
|
||||||
return
|
return
|
||||||
|
|
||||||
ip_fail_until.pop(target, None)
|
log.info("[%s] DC%d%s -> WS pool hit", label, dc, media_tag)
|
||||||
ws_pool.report_success(dc, is_media)
|
|
||||||
stats.connections_ws += 1
|
stats.connections_ws += 1
|
||||||
|
|
||||||
splitter = None
|
splitter = None
|
||||||
try:
|
try:
|
||||||
splitter = MsgSplitter(relay_init, proto_int)
|
splitter = MsgSplitter(proto_int)
|
||||||
log.debug("[%s] MsgSplitter activated for proto 0x%08X",
|
log.debug("[%s] MsgSplitter activated for proto 0x%08X",
|
||||||
label, proto_int)
|
label, proto_int)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -471,15 +367,15 @@ _client_tasks: Set[asyncio.Task] = set()
|
|||||||
|
|
||||||
|
|
||||||
async def _run(stop_event: Optional[asyncio.Event] = None):
|
async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||||
global _server_instance, _server_stop_event
|
global _server_instance, _server_stop_event, cf_h2_pool
|
||||||
|
install_self_pipe_guard(asyncio.get_running_loop())
|
||||||
_server_stop_event = stop_event
|
_server_stop_event = stop_event
|
||||||
|
|
||||||
|
reset_tcp_backoff()
|
||||||
ws_pool.reset()
|
ws_pool.reset()
|
||||||
cf_worker_pool.reset()
|
cf_worker_pool.reset()
|
||||||
ws_blacklist.clear()
|
|
||||||
dc_fail_until.clear()
|
|
||||||
ip_fail_until.clear()
|
|
||||||
_client_tasks.clear()
|
_client_tasks.clear()
|
||||||
|
cf_h2_pool = CfH2Pool() if proxy_config.h2_enabled else None
|
||||||
|
|
||||||
user_cf_domains = proxy_config.cfproxy_user_domains
|
user_cf_domains = proxy_config.cfproxy_user_domains
|
||||||
if user_cf_domains:
|
if user_cf_domains:
|
||||||
@@ -488,8 +384,25 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
start_cfproxy_domain_refresh()
|
start_cfproxy_domain_refresh()
|
||||||
|
|
||||||
secret_bytes = bytes.fromhex(proxy_config.secret)
|
secret_bytes = bytes.fromhex(proxy_config.secret)
|
||||||
|
stopping = False
|
||||||
|
|
||||||
|
# Probe before listening so a domain that resolves to this proxy fails
|
||||||
|
# fast instead of answering itself
|
||||||
|
set_profiled_appdata_len(None)
|
||||||
|
if proxy_config.fake_tls_domain:
|
||||||
|
appdata_len = await probe_appdata_len(proxy_config.fake_tls_domain)
|
||||||
|
set_profiled_appdata_len(appdata_len)
|
||||||
|
if appdata_len is None:
|
||||||
|
log.warning("Fake TLS profile unavailable, ServerHello uses "
|
||||||
|
"random record sizes")
|
||||||
|
else:
|
||||||
|
log.info("Fake TLS profile: first encrypted record %d bytes",
|
||||||
|
appdata_len)
|
||||||
|
|
||||||
def client_cb(r, w):
|
def client_cb(r, w):
|
||||||
|
if stopping:
|
||||||
|
w.close()
|
||||||
|
return
|
||||||
task = asyncio.create_task(_handle_client(r, w, secret_bytes))
|
task = asyncio.create_task(_handle_client(r, w, secret_bytes))
|
||||||
_client_tasks.add(task)
|
_client_tasks.add(task)
|
||||||
task.add_done_callback(_client_tasks.discard)
|
task.add_done_callback(_client_tasks.discard)
|
||||||
@@ -531,6 +444,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
if proxy_config.cfproxy_worker_domains:
|
if proxy_config.cfproxy_worker_domains:
|
||||||
log.info(" CF worker: enabled (%s)",
|
log.info(" CF worker: enabled (%s)",
|
||||||
", ".join(proxy_config.cfproxy_worker_domains))
|
", ".join(proxy_config.cfproxy_worker_domains))
|
||||||
|
if cf_h2_pool is not None:
|
||||||
|
log.info(' CF H2 media: enabled')
|
||||||
if proxy_config.disable_secure:
|
if proxy_config.disable_secure:
|
||||||
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
|
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
@@ -542,11 +457,25 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|
||||||
async def log_stats():
|
async def log_stats():
|
||||||
|
next_stats = time.monotonic() + 60
|
||||||
|
next_tick = time.monotonic() + 5
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(5)
|
||||||
bl = ', '.join(f'DC{k}' for k in sorted(ws_blacklist)) or 'none'
|
now = time.monotonic()
|
||||||
log.info("stats: %s | ws_bl: %s", stats.summary(), bl)
|
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:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -563,6 +492,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
except (asyncio.CancelledError, Exception):
|
except (asyncio.CancelledError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
serve_task = stop_task = watchdog_task = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
serve_task = asyncio.create_task(server.serve_forever())
|
serve_task = asyncio.create_task(server.serve_forever())
|
||||||
@@ -585,19 +515,12 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
waiters, return_when=asyncio.FIRST_COMPLETED)
|
waiters, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
|
||||||
if stop_task is not None and stop_task in done:
|
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
|
break
|
||||||
|
|
||||||
await _quiet_cancel(watchdog_task)
|
await _quiet_cancel(watchdog_task)
|
||||||
await _quiet_cancel(serve_task)
|
await _quiet_cancel(serve_task)
|
||||||
|
if stop_task is not None:
|
||||||
|
await _quiet_cancel(stop_task)
|
||||||
log.warning(
|
log.warning(
|
||||||
"Listening socket died, restarting server")
|
"Listening socket died, restarting server")
|
||||||
server.close()
|
server.close()
|
||||||
@@ -622,6 +545,22 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
log.warning("Server restored, listening on %s:%d",
|
log.warning("Server restored, listening on %s:%d",
|
||||||
proxy_config.host, proxy_config.port)
|
proxy_config.host, proxy_config.port)
|
||||||
finally:
|
finally:
|
||||||
|
stopping = True
|
||||||
|
server.close()
|
||||||
|
clients = list(_client_tasks)
|
||||||
|
for task in clients:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*clients, return_exceptions=True)
|
||||||
|
for task in (serve_task, watchdog_task, stop_task):
|
||||||
|
if task is not None:
|
||||||
|
await _quiet_cancel(task)
|
||||||
|
await server.wait_closed()
|
||||||
|
await ws_pool.close()
|
||||||
|
await cf_worker_pool.close()
|
||||||
|
if cf_h2_pool is not None:
|
||||||
|
await cf_h2_pool.close()
|
||||||
|
cf_h2_pool = None
|
||||||
|
_server_instance = _server_stop_event = None
|
||||||
log_stats_task.cancel()
|
log_stats_task.cancel()
|
||||||
try:
|
try:
|
||||||
await log_stats_task
|
await log_stats_task
|
||||||
@@ -663,16 +602,20 @@ def main():
|
|||||||
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
|
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
|
||||||
help='Socket send/recv buffer size in KB (default 256)')
|
help='Socket send/recv buffer size in KB (default 256)')
|
||||||
ap.add_argument('--pool-size', type=int, default=4, metavar='N',
|
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,
|
ap.add_argument('--cfproxy-domain', action='append', default=None,
|
||||||
metavar='DOMAIN',
|
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)')
|
'(repeatable for multiple domains)')
|
||||||
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
|
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
|
||||||
metavar='DOMAIN',
|
metavar='DOMAIN',
|
||||||
help='Cloudflare Worker domain for WS fallback '
|
help='Cloudflare Worker domain for WS fallback '
|
||||||
'(tried before other fallback methods, '
|
'(tried before other fallback methods, '
|
||||||
'repeatable for multiple domains)')
|
'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',
|
ap.add_argument('--no-cfproxy', action='store_true',
|
||||||
help='Disable Cloudflare proxy fallback')
|
help='Disable Cloudflare proxy fallback')
|
||||||
ap.add_argument('--no-secure', action='store_true',
|
ap.add_argument('--no-secure', action='store_true',
|
||||||
@@ -728,6 +671,7 @@ def main():
|
|||||||
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
||||||
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
|
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_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.disable_secure = args.no_secure
|
||||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||||
proxy_config.proxy_protocol = args.proxy_protocol
|
proxy_config.proxy_protocol = args.proxy_protocol
|
||||||
@@ -756,6 +700,8 @@ def main():
|
|||||||
root.addHandler(fh)
|
root.addHandler(fh)
|
||||||
|
|
||||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||||
|
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||||
|
logging.getLogger('httpcore').setLevel(logging.WARNING)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(_run())
|
asyncio.run(_run())
|
||||||
|
|||||||
+26
-5
@@ -3,7 +3,9 @@ import urllib.request
|
|||||||
import http.client
|
import http.client
|
||||||
import ssl
|
import ssl
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
|
|
||||||
@@ -31,7 +33,8 @@ PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
|||||||
RESERVED_FIRST_BYTES = {0xEF}
|
RESERVED_FIRST_BYTES = {0xEF}
|
||||||
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
||||||
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
||||||
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02',
|
||||||
|
b'\x4F\x50\x54\x49'}
|
||||||
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||||
|
|
||||||
_GITHUB_IPS: Dict[str, str] = {
|
_GITHUB_IPS: Dict[str, str] = {
|
||||||
@@ -58,11 +61,11 @@ WS_PATH = '/apiws'
|
|||||||
WS_PATH_TEST = WS_PATH + '_test'
|
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:
|
if dc == 203:
|
||||||
dc = 2
|
dc = 2
|
||||||
if not is_media:
|
if not is_media:
|
||||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
return [f'kws{dc}.web.telegram.org']
|
||||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +143,24 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
|||||||
return super().https_open(req)
|
return super().https_open(req)
|
||||||
|
|
||||||
|
|
||||||
|
def set_x25519(context: ssl.SSLContext) -> None:
|
||||||
|
try:
|
||||||
|
context.set_ecdh_curve('X25519')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
|
||||||
|
bundle_dir = getattr(sys, '_MEIPASS', None)
|
||||||
|
cafile = (os.path.join(bundle_dir, 'certifi', 'cacert.pem')
|
||||||
|
if bundle_dir else certifi.where())
|
||||||
|
context = ssl.create_default_context(cafile=cafile)
|
||||||
|
context.load_default_certs()
|
||||||
|
set_x25519(context)
|
||||||
|
context.check_hostname = check_hostname
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
def build_github_opener() -> urllib.request.OpenerDirector:
|
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||||
context = ssl.create_default_context(cafile=certifi.where())
|
return urllib.request.build_opener(
|
||||||
return urllib.request.build_opener(_PinnedHTTPSHandler(context=context))
|
_PinnedHTTPSHandler(context=create_ssl_context()))
|
||||||
@@ -37,6 +37,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pyperclip==1.9.0",
|
"pyperclip==1.9.0",
|
||||||
"certifi",
|
"certifi",
|
||||||
|
"httpx[http2]==0.28.1",
|
||||||
|
|
||||||
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
"psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
"cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'",
|
||||||
|
|||||||
+156
-13
@@ -1,8 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
from proxy._aes import Cipher, algorithms, modes
|
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 (
|
from proxy.utils import (
|
||||||
PROTO_ABRIDGED_INT,
|
PROTO_ABRIDGED_INT,
|
||||||
PROTO_INTERMEDIATE_INT,
|
PROTO_INTERMEDIATE_INT,
|
||||||
@@ -36,9 +41,10 @@ def _intermediate(payload: bytes) -> bytes:
|
|||||||
class MsgSplitterTest(unittest.TestCase):
|
class MsgSplitterTest(unittest.TestCase):
|
||||||
def _split(self, proto_int, packets, chunk_sizes=None):
|
def _split(self, proto_int, packets, chunk_sizes=None):
|
||||||
relay_init = _relay_init()
|
relay_init = _relay_init()
|
||||||
splitter = MsgSplitter(relay_init, proto_int)
|
splitter = MsgSplitter(proto_int)
|
||||||
enc = _encryptor(relay_init)
|
enc = _encryptor(relay_init)
|
||||||
stream = enc.update(b''.join(packets))
|
plain = b''.join(packets)
|
||||||
|
stream = enc.update(plain)
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
if chunk_sizes is None:
|
if chunk_sizes is None:
|
||||||
@@ -52,8 +58,10 @@ class MsgSplitterTest(unittest.TestCase):
|
|||||||
chunks.append(stream[offset:])
|
chunks.append(stream[offset:])
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
offset = 0
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
parts.extend(splitter.split(chunk))
|
parts.extend(splitter.split(chunk, plain[offset:offset + len(chunk)]))
|
||||||
|
offset += len(chunk)
|
||||||
return splitter, stream, parts
|
return splitter, stream, parts
|
||||||
|
|
||||||
def test_abridged_stream_splits_into_packets(self):
|
def test_abridged_stream_splits_into_packets(self):
|
||||||
@@ -90,27 +98,162 @@ class MsgSplitterTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(parts), 8)
|
self.assertEqual(len(parts), 8)
|
||||||
|
|
||||||
def test_empty_chunk_yields_nothing(self):
|
def test_empty_chunk_yields_nothing(self):
|
||||||
splitter = MsgSplitter(_relay_init(), PROTO_INTERMEDIATE_INT)
|
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||||
self.assertEqual(splitter.split(b''), [])
|
self.assertEqual(splitter.split(b'', b''), [])
|
||||||
|
|
||||||
def test_zero_length_packet_disables_splitting(self):
|
def test_zero_length_packet_disables_splitting(self):
|
||||||
relay_init = _relay_init()
|
relay_init = _relay_init()
|
||||||
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
|
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||||
enc = _encryptor(relay_init)
|
enc = _encryptor(relay_init)
|
||||||
stream = enc.update((0).to_bytes(4, 'little') + b'tail')
|
plain = (0).to_bytes(4, 'little') + b'tail'
|
||||||
parts = splitter.split(stream)
|
stream = enc.update(plain)
|
||||||
|
parts = splitter.split(stream, plain)
|
||||||
self.assertEqual(parts, [stream])
|
self.assertEqual(parts, [stream])
|
||||||
self.assertEqual(splitter.split(b'raw'), [b'raw'])
|
self.assertEqual(splitter.split(b'raw', b'raw'), [b'raw'])
|
||||||
|
|
||||||
def test_flush_returns_buffered_tail_once(self):
|
def test_flush_returns_buffered_tail_once(self):
|
||||||
relay_init = _relay_init()
|
relay_init = _relay_init()
|
||||||
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
|
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||||
enc = _encryptor(relay_init)
|
enc = _encryptor(relay_init)
|
||||||
partial = enc.update(_intermediate(b'x' * 32)[:10])
|
plain = _intermediate(b'x' * 32)[:10]
|
||||||
self.assertEqual(splitter.split(partial), [])
|
partial = enc.update(plain)
|
||||||
|
self.assertEqual(splitter.split(partial, plain), [])
|
||||||
self.assertEqual(splitter.flush(), [partial])
|
self.assertEqual(splitter.flush(), [partial])
|
||||||
self.assertEqual(splitter.flush(), [])
|
self.assertEqual(splitter.flush(), [])
|
||||||
|
|
||||||
|
def test_extended_and_quick_ack_headers_can_cross_chunk_boundaries(self):
|
||||||
|
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
|
||||||
|
(PROTO_INTERMEDIATE_INT, _intermediate),
|
||||||
|
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
|
||||||
|
for size in (16, 512, 65536):
|
||||||
|
packet = bytearray(frame(b'p' * size))
|
||||||
|
packet[0 if proto == PROTO_ABRIDGED_INT else 3] |= 0x80
|
||||||
|
packets = [bytes(packet), frame(b't' * 8)]
|
||||||
|
for header_split in (1, 2, 3):
|
||||||
|
with self.subTest(proto=proto, size=size, header_split=header_split):
|
||||||
|
_, stream, parts = self._split(
|
||||||
|
proto, packets, chunk_sizes=[header_split, 1, 11, size - 8])
|
||||||
|
self.assertEqual(parts, [stream[:len(packet)], stream[len(packet):]])
|
||||||
|
|
||||||
|
|
||||||
|
class WsReencryptTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_relay_preserves_encrypted_packets_and_incomplete_tail(self):
|
||||||
|
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
|
||||||
|
(PROTO_INTERMEDIATE_INT, _intermediate),
|
||||||
|
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
|
||||||
|
with self.subTest(proto=proto):
|
||||||
|
packets = [frame(b'a' * 8), frame(b'b' * 16),
|
||||||
|
frame(b'c' * 70000), frame(b'd' * 32)[:7]]
|
||||||
|
client_init, relay_init = _relay_init(), _relay_init()
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
reader.feed_data(_encryptor(client_init).update(b''.join(packets)))
|
||||||
|
reader.feed_eof()
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def send(data):
|
||||||
|
sent.append(data)
|
||||||
|
|
||||||
|
async def send_batch(parts):
|
||||||
|
sent.extend(parts)
|
||||||
|
|
||||||
|
async def recv():
|
||||||
|
await asyncio.Future()
|
||||||
|
|
||||||
|
ws = SimpleNamespace(send=send, send_batch=send_batch, recv=recv,
|
||||||
|
close=AsyncMock())
|
||||||
|
writer = Mock(wait_closed=AsyncMock())
|
||||||
|
ctx = SimpleNamespace(clt_dec=_encryptor(client_init), tg_enc=_encryptor(relay_init))
|
||||||
|
await asyncio.wait_for(bridge_ws_reencrypt(
|
||||||
|
reader, writer, ws, 'framing-test', ctx, splitter=MsgSplitter(proto)), 1)
|
||||||
|
decoder = _encryptor(relay_init)
|
||||||
|
self.assertEqual([decoder.update(part) for part in sent], packets)
|
||||||
|
ws.close.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
|
||||||
|
class Cipher:
|
||||||
|
def update(self, data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
class Socket:
|
||||||
|
def __init__(self):
|
||||||
|
self.queue = asyncio.Queue()
|
||||||
|
self.sending = asyncio.Event()
|
||||||
|
self.release = asyncio.Event()
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
async def send(self, data):
|
||||||
|
self.sent.append(data)
|
||||||
|
self.sending.set()
|
||||||
|
await self.release.wait()
|
||||||
|
|
||||||
|
async def recv(self):
|
||||||
|
return await self.queue.get()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Writer:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = bytearray()
|
||||||
|
self.written = asyncio.Event()
|
||||||
|
self.release = asyncio.Event()
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.data.extend(data)
|
||||||
|
self.written.set()
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
await self.release.wait()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def wait_closed(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ws, writer = Socket(), Writer()
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
|
||||||
|
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
|
||||||
|
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
|
||||||
|
try:
|
||||||
|
reader.feed_data(b'private-native-payload')
|
||||||
|
await asyncio.wait_for(ws.sending.wait(), 1)
|
||||||
|
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
|
||||||
|
self.assertIsNotNone(activity.sending)
|
||||||
|
self.assertEqual(activity.ws_up, 0)
|
||||||
|
log_ws_flow(time.monotonic())
|
||||||
|
# A reply arriving before send drain finishes must not create
|
||||||
|
# a spurious wait for another response when drain resumes.
|
||||||
|
await ws.queue.put(b'private-upstream-payload')
|
||||||
|
await asyncio.wait_for(writer.written.wait(), 1)
|
||||||
|
self.assertEqual(activity.ws_down, len(writer.data))
|
||||||
|
self.assertEqual(activity.native_down, 0)
|
||||||
|
self.assertIsNotNone(activity.writing_native)
|
||||||
|
log_ws_flow(time.monotonic())
|
||||||
|
ws.release.set()
|
||||||
|
writer.release.set()
|
||||||
|
for _ in range(100):
|
||||||
|
if activity.native_down and activity.ws_up:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
self.assertEqual(activity.native_down, len(writer.data))
|
||||||
|
self.assertIsNone(activity.awaiting_rx)
|
||||||
|
self.assertEqual(ws.sent, [b'private-native-payload'])
|
||||||
|
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
|
||||||
|
reader.feed_eof()
|
||||||
|
await asyncio.wait_for(bridge, 1)
|
||||||
|
finally:
|
||||||
|
bridge.cancel()
|
||||||
|
await asyncio.gather(bridge, return_exceptions=True)
|
||||||
|
self.assertFalse(WsActivity.active)
|
||||||
|
output = '\n'.join(captured.output)
|
||||||
|
self.assertIn('WS FLOW DC203', output)
|
||||||
|
self.assertIn('WS END DC203', output)
|
||||||
|
self.assertNotIn('private-', output)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+1103
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
|||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from proxy import event_loop
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_replacement(loop, old_writer, timeout=1):
|
||||||
|
async def wait():
|
||||||
|
while loop._csock is old_writer:
|
||||||
|
await asyncio.sleep(.005)
|
||||||
|
await asyncio.wait_for(wait(), timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _wake_later(loop, callback):
|
||||||
|
def send():
|
||||||
|
time.sleep(.03)
|
||||||
|
loop.call_soon_threadsafe(callback)
|
||||||
|
thread = threading.Thread(target=send, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
|
||||||
|
class WindowsSelfPipeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.loop = asyncio.ProactorEventLoop()
|
||||||
|
self.addCleanup(self.loop.close)
|
||||||
|
|
||||||
|
def test_eof_replaces_sockets_and_stops_idle_callbacks(self):
|
||||||
|
loop = self.loop
|
||||||
|
read = Mock(wraps=loop._loop_self_reading)
|
||||||
|
loop._loop_self_reading = read
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
# Install after the original callback has armed a read, as can
|
||||||
|
# happen when the proxy starts in an already running event loop.
|
||||||
|
await asyncio.sleep(.01)
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
callback = loop._loop_self_reading
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
self.assertIs(loop._loop_self_reading, callback)
|
||||||
|
old_reader, old_writer = loop._ssock, loop._csock
|
||||||
|
internal_fds = loop._internal_fds
|
||||||
|
with patch.object(event_loop.socket, 'socketpair', wraps=socket.socketpair) as pair:
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
self.assertEqual(pair.call_count, 1)
|
||||||
|
self.assertEqual(old_reader.fileno(), -1)
|
||||||
|
self.assertEqual(old_writer.fileno(), -1)
|
||||||
|
self.assertEqual(loop._internal_fds, internal_fds)
|
||||||
|
self.assertFalse(loop._self_reading_future.done())
|
||||||
|
read.reset_mock()
|
||||||
|
await asyncio.sleep(.05)
|
||||||
|
read.assert_not_called()
|
||||||
|
|
||||||
|
completed = loop.create_future()
|
||||||
|
thread = _wake_later(loop, lambda: completed.set_result(True))
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
self.assertTrue(await asyncio.wait_for(completed, 1))
|
||||||
|
self.assertLess(time.monotonic() - started, .5)
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=1)
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||||
|
loop.run_until_complete(run())
|
||||||
|
self.assertEqual(len(messages.output), 1)
|
||||||
|
self.assertIn('EOF', messages.output[0])
|
||||||
|
|
||||||
|
def test_repeated_eof_preserves_existing_tcp_connection(self):
|
||||||
|
async def run():
|
||||||
|
loop = self.loop
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
finished = loop.create_future()
|
||||||
|
|
||||||
|
async def echo(reader, writer):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await reader.read(128)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
finished.set_result(None)
|
||||||
|
|
||||||
|
server = await asyncio.start_server(echo, '127.0.0.1', 0)
|
||||||
|
writer = None
|
||||||
|
try:
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
reader, writer = await asyncio.open_connection('127.0.0.1', port)
|
||||||
|
for index in range(5):
|
||||||
|
old_writer = loop._csock
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
payload = f'packet {index}'.encode()
|
||||||
|
writer.write(payload)
|
||||||
|
await writer.drain()
|
||||||
|
data = await asyncio.wait_for(reader.readexactly(len(payload)), 1)
|
||||||
|
self.assertEqual(data, payload)
|
||||||
|
finally:
|
||||||
|
if writer is not None:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
await asyncio.wait_for(finished, 1)
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||||
|
self.loop.run_until_complete(run())
|
||||||
|
|
||||||
|
def test_reset_rebuilds_but_cancelled_and_stale_callbacks_do_not(self):
|
||||||
|
async def run():
|
||||||
|
loop = self.loop
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
old_writer = loop._csock
|
||||||
|
pending = loop._self_reading_future
|
||||||
|
pending.cancel()
|
||||||
|
failed = loop.create_future()
|
||||||
|
failed.set_exception(ConnectionResetError('self-pipe reset'))
|
||||||
|
loop._self_reading_future = failed
|
||||||
|
loop._loop_self_reading(failed)
|
||||||
|
self.assertIsNot(loop._csock, old_writer)
|
||||||
|
replacement = loop._csock
|
||||||
|
with patch.object(loop, 'call_exception_handler') as report:
|
||||||
|
loop._loop_self_reading(failed) # queued callback from the old pipe
|
||||||
|
loop._loop_self_reading(pending) # cancellation during shutdown
|
||||||
|
report.assert_not_called()
|
||||||
|
self.assertIs(loop._csock, replacement)
|
||||||
|
await asyncio.sleep(.01)
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||||
|
self.loop.run_until_complete(run())
|
||||||
|
self.assertEqual(len(messages.output), 1)
|
||||||
|
|
||||||
|
def test_reset_on_read_armed_before_installation_is_recovered(self):
|
||||||
|
async def run():
|
||||||
|
loop = self.loop
|
||||||
|
await asyncio.sleep(.01)
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
old_writer = loop._csock
|
||||||
|
loop._self_reading_future.set_exception(ConnectionResetError('self-pipe reset'))
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
self.assertFalse(loop._self_reading_future.done())
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||||
|
self.loop.run_until_complete(run())
|
||||||
|
self.assertEqual(len(messages.output), 1)
|
||||||
|
|
||||||
|
def test_recovery_in_background_proxy_thread(self):
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
old_writer = loop._csock
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
self.assertFalse(loop._self_reading_future.done())
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||||
|
thread = threading.Thread(target=worker, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
self.assertFalse(errors, errors)
|
||||||
|
|
||||||
|
def test_allocation_failure_retries_without_spinning(self):
|
||||||
|
async def run():
|
||||||
|
loop = self.loop
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
original_pair = socket.socketpair
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
def allocate():
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
raise OSError('temporarily out of sockets')
|
||||||
|
return original_pair()
|
||||||
|
|
||||||
|
with patch.object(event_loop.socket, 'socketpair', side_effect=allocate):
|
||||||
|
old_reader, old_writer = loop._ssock, loop._csock
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await asyncio.sleep(.05)
|
||||||
|
self.assertEqual(attempts, 1)
|
||||||
|
self.assertIs(loop._csock, old_writer)
|
||||||
|
self.assertNotEqual(old_reader.fileno(), -1)
|
||||||
|
self.assertIsNone(loop._self_reading_future)
|
||||||
|
await _wait_for_replacement(loop, old_writer, timeout=2)
|
||||||
|
self.assertEqual(attempts, 2)
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||||
|
self.loop.run_until_complete(run())
|
||||||
|
|
||||||
|
def test_signal_wakeup_fd_is_moved_only_when_owned_by_the_loop(self):
|
||||||
|
async def run():
|
||||||
|
loop = self.loop
|
||||||
|
event_loop.install_self_pipe_guard(loop)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
external_reader, external_writer = socket.socketpair()
|
||||||
|
external_writer.setblocking(False)
|
||||||
|
try:
|
||||||
|
for external in (False, True):
|
||||||
|
with self.subTest(external=external):
|
||||||
|
if external:
|
||||||
|
signal.set_wakeup_fd(external_writer.fileno())
|
||||||
|
old_writer = loop._csock
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
fd = signal.set_wakeup_fd(-1)
|
||||||
|
expected = external_writer if external else loop._csock
|
||||||
|
self.assertEqual(fd, expected.fileno())
|
||||||
|
signal.set_wakeup_fd(loop._csock.fileno())
|
||||||
|
finally:
|
||||||
|
external_reader.close()
|
||||||
|
external_writer.close()
|
||||||
|
|
||||||
|
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||||
|
self.loop.run_until_complete(run())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
|
||||||
|
class ProxySelfPipeTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_proxy_without_h2_can_stop_from_thread_after_eof(self):
|
||||||
|
from proxy import tg_ws_proxy
|
||||||
|
from proxy.config import ProxyConfig
|
||||||
|
|
||||||
|
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0, cfproxy_h2_media=False)
|
||||||
|
stop = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with patch.object(tg_ws_proxy, 'proxy_config', config), \
|
||||||
|
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
|
||||||
|
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
|
||||||
|
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()), \
|
||||||
|
self.assertLogs(event_loop.log, 'WARNING'):
|
||||||
|
task = asyncio.create_task(tg_ws_proxy._run(stop))
|
||||||
|
thread = None
|
||||||
|
try:
|
||||||
|
async def listening():
|
||||||
|
while tg_ws_proxy._server_instance is None:
|
||||||
|
await asyncio.sleep(.005)
|
||||||
|
await asyncio.wait_for(listening(), 1)
|
||||||
|
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
|
||||||
|
old_writer = loop._csock
|
||||||
|
old_writer.shutdown(socket.SHUT_WR)
|
||||||
|
await _wait_for_replacement(loop, old_writer)
|
||||||
|
thread = _wake_later(loop, stop.set)
|
||||||
|
started = time.monotonic()
|
||||||
|
await asyncio.wait_for(task, 1)
|
||||||
|
self.assertLess(time.monotonic() - started, .5)
|
||||||
|
self.assertIsNone(tg_ws_proxy._server_instance)
|
||||||
|
finally:
|
||||||
|
if thread is not None:
|
||||||
|
thread.join(timeout=1)
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+302
-9
@@ -1,24 +1,111 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from proxy import fake_tls
|
||||||
from proxy.fake_tls import (
|
from proxy.fake_tls import (
|
||||||
|
APPDATA_MIN,
|
||||||
CLIENT_RANDOM_LEN,
|
CLIENT_RANDOM_LEN,
|
||||||
CLIENT_RANDOM_OFFSET,
|
CLIENT_RANDOM_OFFSET,
|
||||||
|
EXT_KEY_SHARE,
|
||||||
|
EXT_SUPPORTED_VERSIONS,
|
||||||
|
FALLBACK_APPDATA_RANGE,
|
||||||
|
GROUP_X25519,
|
||||||
|
GROUP_X25519MLKEM768,
|
||||||
|
MLKEM768_CIPHERTEXT_LEN,
|
||||||
SESSION_ID_LEN,
|
SESSION_ID_LEN,
|
||||||
SESSION_ID_OFFSET,
|
SESSION_ID_OFFSET,
|
||||||
TLS_APPDATA_MAX,
|
TLS_APPDATA_MAX,
|
||||||
|
TLS_RECORD_APPDATA,
|
||||||
|
TLS_RECORD_CCS,
|
||||||
TLS_RECORD_HANDSHAKE,
|
TLS_RECORD_HANDSHAKE,
|
||||||
|
X25519_KEY_LEN,
|
||||||
|
X25519MLKEM768_CLIENT_SHARE_LEN,
|
||||||
build_server_hello,
|
build_server_hello,
|
||||||
|
first_appdata_record_len,
|
||||||
|
parse_client_hello,
|
||||||
|
probe_appdata_len,
|
||||||
|
set_profiled_appdata_len,
|
||||||
verify_client_hello,
|
verify_client_hello,
|
||||||
wrap_tls_record,
|
wrap_tls_record,
|
||||||
)
|
)
|
||||||
|
|
||||||
SECRET = bytes.fromhex('00112233445566778899aabbccddeeff')
|
SECRET = bytes.fromhex('00112233445566778899aabbccddeeff')
|
||||||
|
|
||||||
|
# Telegram's current ClientHello offers these, like Chrome
|
||||||
|
TELEGRAM_SUITES = (0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f)
|
||||||
|
TELEGRAM_SHARES = ((GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_SHARE_LEN),
|
||||||
|
(GROUP_X25519, X25519_KEY_LEN))
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_client_hello(body: bytearray, secret: bytes, timestamp: int) -> bytes:
|
||||||
|
digest = hmac.new(secret, bytes(body), hashlib.sha256).digest()
|
||||||
|
client_random = bytearray(digest[:CLIENT_RANDOM_LEN])
|
||||||
|
ts_bytes = struct.pack('<I', timestamp)
|
||||||
|
for i in range(4):
|
||||||
|
client_random[28 + i] = digest[28 + i] ^ ts_bytes[i]
|
||||||
|
body[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = client_random
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_client_hello(suites=TELEGRAM_SUITES, shares=TELEGRAM_SHARES,
|
||||||
|
secret: bytes = SECRET) -> bytes:
|
||||||
|
key_share_list = b''.join(struct.pack('>HH', group, length) + os.urandom(length)
|
||||||
|
for group, length in shares)
|
||||||
|
extensions = (
|
||||||
|
struct.pack('>HH', 0x0000, 0)
|
||||||
|
+ struct.pack('>HHH', EXT_KEY_SHARE, 2 + len(key_share_list),
|
||||||
|
len(key_share_list))
|
||||||
|
+ key_share_list
|
||||||
|
+ struct.pack('>HHB', EXT_SUPPORTED_VERSIONS, 3, 2) + b'\x03\x04'
|
||||||
|
)
|
||||||
|
hello = (
|
||||||
|
b'\x03\x03' + b'\x00' * CLIENT_RANDOM_LEN
|
||||||
|
+ b'\x20' + os.urandom(SESSION_ID_LEN)
|
||||||
|
+ struct.pack('>H', 2 * len(suites))
|
||||||
|
+ b''.join(struct.pack('>H', s) for s in suites)
|
||||||
|
+ b'\x01\x00'
|
||||||
|
+ struct.pack('>H', len(extensions)) + extensions
|
||||||
|
)
|
||||||
|
message = b'\x01' + struct.pack('>I', len(hello))[1:] + hello
|
||||||
|
body = bytearray(b'\x16\x03\x01' + struct.pack('>H', len(message)) + message)
|
||||||
|
return _sign_client_hello(body, secret, int(time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_records(data: bytes):
|
||||||
|
records = []
|
||||||
|
pos = 0
|
||||||
|
while pos < len(data):
|
||||||
|
rtype = data[pos]
|
||||||
|
length = struct.unpack_from('>H', data, pos + 3)[0]
|
||||||
|
records.append((rtype, data[pos + 5:pos + 5 + length]))
|
||||||
|
pos += 5 + length
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _server_hello_fields(record: bytes):
|
||||||
|
# record = handshake message: type(1) len(3) version(2) random(32) sid ...
|
||||||
|
pos = 4 + 2 + 32
|
||||||
|
pos += 1 + record[pos]
|
||||||
|
cipher = struct.unpack_from('>H', record, pos)[0]
|
||||||
|
pos += 3
|
||||||
|
ext_end = pos + 2 + struct.unpack_from('>H', record, pos)[0]
|
||||||
|
pos += 2
|
||||||
|
extensions = []
|
||||||
|
while pos < ext_end:
|
||||||
|
ext_type, ext_len = struct.unpack_from('>HH', record, pos)
|
||||||
|
extensions.append((ext_type, record[pos + 4:pos + 4 + ext_len]))
|
||||||
|
pos += 4 + ext_len
|
||||||
|
return cipher, extensions
|
||||||
|
|
||||||
|
|
||||||
def _client_hello(secret: bytes = SECRET, timestamp: int = None,
|
def _client_hello(secret: bytes = SECRET, timestamp: int = None,
|
||||||
session_id: bytes = None) -> bytes:
|
session_id: bytes = None) -> bytes:
|
||||||
@@ -34,15 +121,7 @@ def _client_hello(secret: bytes = SECRET, timestamp: int = None,
|
|||||||
body[5] = 0x01
|
body[5] = 0x01
|
||||||
body[43] = 0x20
|
body[43] = 0x20
|
||||||
body[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN] = session_id
|
body[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN] = session_id
|
||||||
|
return _sign_client_hello(body, secret, timestamp)
|
||||||
digest = hmac.new(secret, bytes(body), hashlib.sha256).digest()
|
|
||||||
client_random = bytearray(digest[:CLIENT_RANDOM_LEN])
|
|
||||||
ts_bytes = struct.pack('<I', timestamp)
|
|
||||||
for i in range(4):
|
|
||||||
client_random[28 + i] = digest[28 + i] ^ ts_bytes[i]
|
|
||||||
|
|
||||||
body[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = client_random
|
|
||||||
return bytes(body)
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyClientHelloTest(unittest.TestCase):
|
class VerifyClientHelloTest(unittest.TestCase):
|
||||||
@@ -80,7 +159,108 @@ class VerifyClientHelloTest(unittest.TestCase):
|
|||||||
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
|
self.assertIsNone(verify_client_hello(bytes(hello), SECRET))
|
||||||
|
|
||||||
|
|
||||||
|
class ParseClientHelloTest(unittest.TestCase):
|
||||||
|
def test_extracts_suites_and_key_shares(self):
|
||||||
|
info = parse_client_hello(_structured_client_hello())
|
||||||
|
self.assertEqual(info.cipher_suites, frozenset(TELEGRAM_SUITES))
|
||||||
|
self.assertEqual(info.key_shares, dict(TELEGRAM_SHARES))
|
||||||
|
|
||||||
|
def test_structured_hello_still_verifies(self):
|
||||||
|
self.assertIsNotNone(verify_client_hello(_structured_client_hello(), SECRET))
|
||||||
|
|
||||||
|
def test_malformed_hello_yields_empty_info(self):
|
||||||
|
self.assertEqual(parse_client_hello(b'\x16\x03\x01\x00'),
|
||||||
|
fake_tls._EMPTY_HELLO_INFO)
|
||||||
|
truncated = _structured_client_hello()[:60]
|
||||||
|
self.assertEqual(parse_client_hello(truncated), fake_tls._EMPTY_HELLO_INFO)
|
||||||
|
|
||||||
|
def test_unstructured_hello_does_not_raise(self):
|
||||||
|
parse_client_hello(_client_hello())
|
||||||
|
|
||||||
|
|
||||||
class BuildServerHelloTest(unittest.TestCase):
|
class BuildServerHelloTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
set_profiled_appdata_len(None)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
set_profiled_appdata_len(None)
|
||||||
|
|
||||||
|
def _build(self, hello: bytes, client_random: bytes = None) -> bytes:
|
||||||
|
client_random = client_random or os.urandom(CLIENT_RANDOM_LEN)
|
||||||
|
return build_server_hello(SECRET, client_random, os.urandom(SESSION_ID_LEN),
|
||||||
|
parse_client_hello(hello))
|
||||||
|
|
||||||
|
def test_flight_is_server_hello_ccs_and_one_appdata_record(self):
|
||||||
|
records = _split_records(self._build(_structured_client_hello()))
|
||||||
|
self.assertEqual([r[0] for r in records],
|
||||||
|
[TLS_RECORD_HANDSHAKE, TLS_RECORD_CCS, TLS_RECORD_APPDATA])
|
||||||
|
self.assertEqual(records[1][1], b'\x01')
|
||||||
|
|
||||||
|
def test_hmac_covers_whole_flight(self):
|
||||||
|
client_random = os.urandom(CLIENT_RANDOM_LEN)
|
||||||
|
response = self._build(_structured_client_hello(), client_random)
|
||||||
|
zeroed = bytearray(response)
|
||||||
|
zeroed[11:11 + 32] = b'\x00' * 32
|
||||||
|
expected = hmac.new(SECRET, client_random + bytes(zeroed),
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
self.assertEqual(response[11:11 + 32], expected)
|
||||||
|
|
||||||
|
def test_mirrors_post_quantum_group_when_offered(self):
|
||||||
|
records = _split_records(self._build(_structured_client_hello()))
|
||||||
|
cipher, extensions = _server_hello_fields(records[0][1])
|
||||||
|
self.assertEqual([e[0] for e in extensions],
|
||||||
|
[EXT_SUPPORTED_VERSIONS, EXT_KEY_SHARE])
|
||||||
|
key_share = extensions[1][1]
|
||||||
|
group, length = struct.unpack_from('>HH', key_share)
|
||||||
|
self.assertEqual(group, GROUP_X25519MLKEM768)
|
||||||
|
self.assertEqual(length, MLKEM768_CIPHERTEXT_LEN + X25519_KEY_LEN)
|
||||||
|
self.assertEqual(len(key_share), 4 + length)
|
||||||
|
self.assertEqual(cipher, 0x1301)
|
||||||
|
|
||||||
|
def test_falls_back_to_x25519(self):
|
||||||
|
hello = _structured_client_hello(shares=((GROUP_X25519, X25519_KEY_LEN),))
|
||||||
|
records = _split_records(self._build(hello))
|
||||||
|
_, extensions = _server_hello_fields(records[0][1])
|
||||||
|
group, length = struct.unpack_from('>HH', extensions[1][1])
|
||||||
|
self.assertEqual((group, length), (GROUP_X25519, X25519_KEY_LEN))
|
||||||
|
# real X25519 public keys are below 2^255
|
||||||
|
self.assertEqual(extensions[1][1][-1] & 0x80, 0)
|
||||||
|
|
||||||
|
def test_ignores_post_quantum_share_of_wrong_size(self):
|
||||||
|
hello = _structured_client_hello(
|
||||||
|
shares=((GROUP_X25519MLKEM768, 100), (GROUP_X25519, X25519_KEY_LEN)))
|
||||||
|
records = _split_records(self._build(hello))
|
||||||
|
_, extensions = _server_hello_fields(records[0][1])
|
||||||
|
self.assertEqual(struct.unpack_from('>H', extensions[1][1])[0], GROUP_X25519)
|
||||||
|
|
||||||
|
def test_picks_cipher_offered_by_client(self):
|
||||||
|
hello = _structured_client_hello(suites=(0x1302, 0x1303))
|
||||||
|
cipher, _ = _server_hello_fields(_split_records(self._build(hello))[0][1])
|
||||||
|
self.assertEqual(cipher, 0x1303)
|
||||||
|
|
||||||
|
def test_telegram_flight_matches_telemt_size(self):
|
||||||
|
# telemt answered every Telegram client with exactly 1290 bytes when the
|
||||||
|
# masking site's first encrypted record was below the 64-byte floor
|
||||||
|
set_profiled_appdata_len(30)
|
||||||
|
self.assertEqual(len(self._build(_structured_client_hello())), 1290)
|
||||||
|
|
||||||
|
def test_uses_profiled_appdata_len(self):
|
||||||
|
set_profiled_appdata_len(3000)
|
||||||
|
records = _split_records(self._build(_structured_client_hello()))
|
||||||
|
self.assertEqual(len(records[2][1]), 3000)
|
||||||
|
|
||||||
|
def test_profiled_len_is_clamped(self):
|
||||||
|
set_profiled_appdata_len(10)
|
||||||
|
self.assertEqual(fake_tls._profiled_appdata_len, APPDATA_MIN)
|
||||||
|
set_profiled_appdata_len(10 ** 6)
|
||||||
|
self.assertEqual(fake_tls._profiled_appdata_len, TLS_APPDATA_MAX)
|
||||||
|
|
||||||
|
def test_fallback_len_stays_in_range(self):
|
||||||
|
for _ in range(20):
|
||||||
|
records = _split_records(self._build(_structured_client_hello()))
|
||||||
|
self.assertGreaterEqual(len(records[2][1]), FALLBACK_APPDATA_RANGE[0])
|
||||||
|
self.assertLessEqual(len(records[2][1]), FALLBACK_APPDATA_RANGE[1])
|
||||||
|
|
||||||
def test_echoes_session_id_and_binds_client_random(self):
|
def test_echoes_session_id_and_binds_client_random(self):
|
||||||
session_id = os.urandom(SESSION_ID_LEN)
|
session_id = os.urandom(SESSION_ID_LEN)
|
||||||
client_random = os.urandom(CLIENT_RANDOM_LEN)
|
client_random = os.urandom(CLIENT_RANDOM_LEN)
|
||||||
@@ -106,6 +286,119 @@ class BuildServerHelloTest(unittest.TestCase):
|
|||||||
self.assertGreater(len(sizes), 1)
|
self.assertGreater(len(sizes), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(rtype: int, length: int) -> bytes:
|
||||||
|
return bytes([rtype]) + b'\x03\x03' + struct.pack('>H', length) + b'\x00' * length
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAppdataRecordLenTest(unittest.TestCase):
|
||||||
|
def test_finds_record_after_server_hello_and_ccs(self):
|
||||||
|
flight = (_record(TLS_RECORD_HANDSHAKE, 122) + _record(TLS_RECORD_CCS, 1)
|
||||||
|
+ _record(TLS_RECORD_APPDATA, 27) + _record(TLS_RECORD_APPDATA, 3000))
|
||||||
|
self.assertEqual(first_appdata_record_len(flight), 27)
|
||||||
|
|
||||||
|
def test_needs_more_data_until_header_arrives(self):
|
||||||
|
partial = _record(TLS_RECORD_HANDSHAKE, 122) + _record(TLS_RECORD_CCS, 1)
|
||||||
|
self.assertIsNone(first_appdata_record_len(partial))
|
||||||
|
self.assertIsNone(first_appdata_record_len(partial + b'\x17\x03'))
|
||||||
|
|
||||||
|
def test_alert_means_no_profile(self):
|
||||||
|
self.assertEqual(first_appdata_record_len(_record(0x15, 2)), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _self_signed_context() -> ssl.SSLContext:
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
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(days=1))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=1))
|
||||||
|
.sign(key, hashes.SHA256()))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cert_path = os.path.join(tmp, 'cert.pem')
|
||||||
|
key_path = os.path.join(tmp, 'key.pem')
|
||||||
|
with open(cert_path, 'wb') as f:
|
||||||
|
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
with open(key_path, 'wb') as f:
|
||||||
|
f.write(key.private_bytes(serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption()))
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(cert_path, key_path)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(getattr(ssl, 'HAS_TLSv1_3', False), 'needs TLS 1.3 in ssl')
|
||||||
|
class ProbeAppdataLenTest(unittest.TestCase):
|
||||||
|
def test_continuous_records_do_not_extend_probe_timeout(self):
|
||||||
|
async def scenario():
|
||||||
|
async def read(_size):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return _record(TLS_RECORD_CCS, 1)
|
||||||
|
|
||||||
|
reader = Mock(read=AsyncMock(side_effect=read))
|
||||||
|
writer = Mock(drain=AsyncMock())
|
||||||
|
with patch.object(fake_tls.asyncio, 'open_connection',
|
||||||
|
AsyncMock(return_value=(reader, writer))):
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
probe_appdata_len('localhost', timeout=0.1), timeout=2)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
self.assertGreater(reader.read.await_count, 1)
|
||||||
|
writer.close.assert_called_once()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
def test_blocked_drain_obeys_probe_timeout(self):
|
||||||
|
async def scenario():
|
||||||
|
reader = Mock(read=AsyncMock())
|
||||||
|
writer = Mock(drain=AsyncMock(side_effect=asyncio.Event().wait))
|
||||||
|
with patch.object(fake_tls.asyncio, 'open_connection',
|
||||||
|
AsyncMock(return_value=(reader, writer))):
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
probe_appdata_len('localhost', timeout=0.1), timeout=2)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
reader.read.assert_not_called()
|
||||||
|
hello = writer.write.call_args[0][0]
|
||||||
|
self.assertEqual(parse_client_hello(hello).key_shares,
|
||||||
|
{GROUP_X25519: X25519_KEY_LEN})
|
||||||
|
writer.close.assert_called_once()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
def test_measures_first_encrypted_record_of_real_tls13_server(self):
|
||||||
|
async def scenario():
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
lambda r, w: None, '127.0.0.1', 0, ssl=_self_signed_context())
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
try:
|
||||||
|
return await probe_appdata_len('127.0.0.1', port, timeout=3)
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
|
||||||
|
length = asyncio.run(scenario())
|
||||||
|
self.assertIsNotNone(length)
|
||||||
|
self.assertGreater(length, 0)
|
||||||
|
|
||||||
|
def test_unreachable_site_gives_no_profile(self):
|
||||||
|
async def scenario():
|
||||||
|
server = await asyncio.start_server(lambda r, w: None, '127.0.0.1', 0)
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
return await probe_appdata_len('127.0.0.1', port, timeout=1)
|
||||||
|
|
||||||
|
self.assertIsNone(asyncio.run(scenario()))
|
||||||
|
|
||||||
|
|
||||||
class WrapTlsRecordTest(unittest.TestCase):
|
class WrapTlsRecordTest(unittest.TestCase):
|
||||||
def test_short_payload_becomes_one_record(self):
|
def test_short_payload_becomes_one_record(self):
|
||||||
wrapped = wrap_tls_record(b'hello')
|
wrapped = wrap_tls_record(b'hello')
|
||||||
|
|||||||
@@ -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()
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOCALES_DIR = Path(__file__).parents[1] / "ui" / "i18n"
|
||||||
|
|
||||||
|
# Values that are intentionally identical across locales
|
||||||
|
# (brand names, technical terms, format-only strings).
|
||||||
|
# Note: settings.language is deliberately "Language" in ru.json —
|
||||||
|
# upstream keeps the language selector label in English on purpose.
|
||||||
|
ALLOWED_UNTRANSLATED = frozenset({
|
||||||
|
"app.name",
|
||||||
|
"button.test_loading",
|
||||||
|
"connectivity.cfworker_title",
|
||||||
|
"connectivity.error_line",
|
||||||
|
"first_run.manual_mtproto",
|
||||||
|
"language.en",
|
||||||
|
"language.ru",
|
||||||
|
"section.cfproxy",
|
||||||
|
"section.cfworker",
|
||||||
|
"settings.language",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _load(code: str) -> dict:
|
||||||
|
with open(LOCALES_DIR / f"{code}.json", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class I18nParityTest(unittest.TestCase):
|
||||||
|
def test_en_ru_key_sets_match(self):
|
||||||
|
en = _load("en")
|
||||||
|
ru = _load("ru")
|
||||||
|
self.assertEqual(
|
||||||
|
set(en), set(ru),
|
||||||
|
f"Missing in ru: {sorted(set(en) - set(ru))}; "
|
||||||
|
f"missing in en: {sorted(set(ru) - set(en))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ru_has_no_untouched_english_leftovers(self):
|
||||||
|
en = _load("en")
|
||||||
|
ru = _load("ru")
|
||||||
|
leftovers = sorted(
|
||||||
|
key for key in set(en) & set(ru)
|
||||||
|
if en[key] == ru[key] and key not in ALLOWED_UNTRANSLATED
|
||||||
|
)
|
||||||
|
self.assertEqual(leftovers, [])
|
||||||
|
|
||||||
|
def test_format_placeholders_match(self):
|
||||||
|
import string
|
||||||
|
|
||||||
|
en = _load("en")
|
||||||
|
ru = _load("ru")
|
||||||
|
for key in set(en) & set(ru):
|
||||||
|
en_fields = {
|
||||||
|
name for _, name, _, _ in string.Formatter().parse(en[key])
|
||||||
|
if name
|
||||||
|
}
|
||||||
|
ru_fields = {
|
||||||
|
name for _, name, _, _ in string.Formatter().parse(ru[key])
|
||||||
|
if name
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
en_fields, ru_fields,
|
||||||
|
f"Placeholder mismatch for key {key!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from http.client import IncompleteRead
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from utils import linux_update, update_check
|
||||||
|
|
||||||
|
|
||||||
|
class _Proc:
|
||||||
|
def __init__(self, returncode, stdout=b'', stderr=b''):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
class AssetNameTest(unittest.TestCase):
|
||||||
|
def test_names_match_release_assets(self):
|
||||||
|
self.assertEqual(linux_update.asset_name('binary'), 'TgWsProxy_linux_amd64')
|
||||||
|
self.assertEqual(linux_update.asset_name('deb'), 'TgWsProxy_linux_amd64.deb')
|
||||||
|
self.assertEqual(linux_update.asset_name('rpm'), 'TgWsProxy_linux_amd64.rpm')
|
||||||
|
|
||||||
|
|
||||||
|
class DetectInstallKindTest(unittest.TestCase):
|
||||||
|
def _detect(self, *, machine='x86_64', tools=(), owned_by=None, writable=True):
|
||||||
|
exe = Path('/usr/bin/tg-ws-proxy')
|
||||||
|
|
||||||
|
def _which(name):
|
||||||
|
return '/usr/bin/' + name if name in tools else None
|
||||||
|
|
||||||
|
def _run_quiet(cmd):
|
||||||
|
return cmd[0] == owned_by
|
||||||
|
|
||||||
|
with mock.patch.object(linux_update.platform, 'machine', return_value=machine), \
|
||||||
|
mock.patch.object(linux_update.shutil, 'which', side_effect=_which), \
|
||||||
|
mock.patch.object(linux_update, '_run_quiet', side_effect=_run_quiet), \
|
||||||
|
mock.patch.object(linux_update.os, 'access', return_value=writable), \
|
||||||
|
mock.patch.object(linux_update, '_is_root', return_value=False):
|
||||||
|
return linux_update.detect_install_kind(exe)
|
||||||
|
|
||||||
|
def test_deb_package_with_pkexec(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._detect(tools=('dpkg-query', 'pkexec', 'apt-get'), owned_by='dpkg-query'),
|
||||||
|
'deb',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rpm_package_with_pkexec(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._detect(tools=('rpm', 'pkexec'), owned_by='rpm'), 'rpm',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_package_without_pkexec_is_unsupported(self):
|
||||||
|
self.assertIsNone(self._detect(tools=('dpkg-query',), owned_by='dpkg-query'))
|
||||||
|
|
||||||
|
def test_aur_install_is_unsupported(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
self._detect(tools=('pacman', 'pkexec'), owned_by='pacman'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_standalone_writable_binary(self):
|
||||||
|
self.assertEqual(self._detect(tools=()), 'binary')
|
||||||
|
|
||||||
|
def test_read_only_dir_is_supported_via_pkexec(self):
|
||||||
|
self.assertEqual(self._detect(tools=('pkexec',), writable=False), 'binary')
|
||||||
|
|
||||||
|
def test_read_only_dir_without_pkexec_is_unsupported(self):
|
||||||
|
self.assertIsNone(self._detect(tools=(), writable=False))
|
||||||
|
|
||||||
|
def test_non_amd64_is_unsupported(self):
|
||||||
|
self.assertIsNone(self._detect(machine='aarch64'))
|
||||||
|
|
||||||
|
|
||||||
|
class InstallCmdTest(unittest.TestCase):
|
||||||
|
package_path = Path(tempfile.gettempdir()) / 'pkg'
|
||||||
|
|
||||||
|
def _cmd(self, kind, tools):
|
||||||
|
with mock.patch.object(
|
||||||
|
linux_update.shutil, 'which',
|
||||||
|
side_effect=lambda n: '/usr/bin/' + n if n in tools else None,
|
||||||
|
), mock.patch.object(linux_update, '_is_root', return_value=False):
|
||||||
|
return linux_update.install_cmd(kind, self.package_path)
|
||||||
|
|
||||||
|
def test_deb_prefers_apt_get_and_elevates(self):
|
||||||
|
cmd = self._cmd('deb', ('apt-get',))
|
||||||
|
self.assertEqual(cmd[:3], ['pkexec', 'apt-get', 'install'])
|
||||||
|
self.assertEqual(cmd[-1], str(self.package_path))
|
||||||
|
|
||||||
|
def test_deb_falls_back_to_dpkg(self):
|
||||||
|
self.assertEqual(self._cmd('deb', ()), ['pkexec', 'dpkg', '-i', str(self.package_path)])
|
||||||
|
|
||||||
|
def test_rpm_prefers_dnf_without_removing_packages(self):
|
||||||
|
cmd = self._cmd('rpm', ('dnf',))
|
||||||
|
self.assertEqual(cmd, ['pkexec', 'dnf', 'install', '-y', str(self.package_path)])
|
||||||
|
self.assertNotIn('--allowerasing', cmd)
|
||||||
|
|
||||||
|
def test_rpm_falls_back_to_rpm_binary(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_kind_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self._cmd('binary', ('apt-get',))
|
||||||
|
|
||||||
|
def test_root_does_not_use_pkexec(self):
|
||||||
|
with mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||||
|
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||||
|
self.assertEqual(
|
||||||
|
linux_update.install_cmd('deb', self.package_path),
|
||||||
|
['dpkg', '-i', str(self.package_path)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyUpdateTest(unittest.TestCase):
|
||||||
|
def _download(self, payload=b'new'):
|
||||||
|
def _fn(url, dest_dir, suffix='.tmp', digest=''):
|
||||||
|
path = Path(dest_dir) / ('new' + suffix)
|
||||||
|
path.write_bytes(payload)
|
||||||
|
return path
|
||||||
|
return _fn
|
||||||
|
|
||||||
|
def test_writable_binary_is_replaced_in_place(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'TgWsProxy'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||||
|
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||||
|
|
||||||
|
self.assertEqual(exe.read_bytes(), b'new')
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'linux', 'Linux executable permissions')
|
||||||
|
def test_replaced_binary_has_executable_permissions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'TgWsProxy'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||||
|
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||||
|
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||||
|
|
||||||
|
def test_read_only_dir_binary_is_installed_with_elevation(self):
|
||||||
|
exe = Path('/usr/local/bin/TgWsProxy')
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||||
|
path = Path(tmp) / ('new' + suffix)
|
||||||
|
path.write_bytes(b'new')
|
||||||
|
return path
|
||||||
|
|
||||||
|
run = mock.Mock(return_value=_Proc(0))
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch.object(linux_update.os, 'access', return_value=False), \
|
||||||
|
mock.patch.object(linux_update, '_is_root', return_value=False), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', _download), \
|
||||||
|
mock.patch.object(linux_update.subprocess, 'run', run):
|
||||||
|
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||||
|
|
||||||
|
cmd = run.call_args[0][0]
|
||||||
|
self.assertEqual(cmd[:3], ['pkexec', 'sh', '-c'])
|
||||||
|
self.assertEqual(cmd[-1], str(exe))
|
||||||
|
|
||||||
|
def test_deb_install_failure_returns_stderr(self):
|
||||||
|
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error'))
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', self._download()), \
|
||||||
|
mock.patch.object(linux_update.subprocess, 'run', run), \
|
||||||
|
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||||
|
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||||
|
err = linux_update.apply_update('deb', 'https://x.invalid/a.deb')
|
||||||
|
|
||||||
|
self.assertEqual(err, 'dpkg: error')
|
||||||
|
|
||||||
|
def test_deb_install_success_removes_downloaded_package(self):
|
||||||
|
holder = {}
|
||||||
|
|
||||||
|
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||||
|
path = Path(dest_dir) / ('pkg' + suffix)
|
||||||
|
path.write_bytes(b'deb')
|
||||||
|
holder['path'] = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', _download), \
|
||||||
|
mock.patch.object(linux_update.subprocess, 'run',
|
||||||
|
return_value=_Proc(0)), \
|
||||||
|
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||||
|
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||||
|
self.assertIsNone(linux_update.apply_update('deb', 'https://x.invalid/a.deb'))
|
||||||
|
|
||||||
|
self.assertFalse(holder['path'].exists())
|
||||||
|
|
||||||
|
def test_digest_is_passed_to_the_downloader(self):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||||
|
seen['digest'] = digest
|
||||||
|
path = Path(dest_dir) / ('new' + suffix)
|
||||||
|
path.write_bytes(b'new')
|
||||||
|
return path
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'TgWsProxy'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch('utils.linux_update.download_asset', _download):
|
||||||
|
linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
|
||||||
|
|
||||||
|
self.assertEqual(seen['digest'], 'sha256:ab')
|
||||||
|
|
||||||
|
def test_download_failure_is_reported(self):
|
||||||
|
for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
|
||||||
|
with self.subTest(error=error), \
|
||||||
|
mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
|
||||||
|
mock.patch.object(linux_update, 'download_asset', side_effect=error), \
|
||||||
|
mock.patch.object(linux_update, '_run_install') as install:
|
||||||
|
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error))
|
||||||
|
install.assert_not_called()
|
||||||
|
|
||||||
|
def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self):
|
||||||
|
for kind in ('binary', 'deb', 'rpm'):
|
||||||
|
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'TgWsProxy'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
opener = mock.Mock()
|
||||||
|
opener.open.return_value = io.BytesIO(b'corrupt')
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch.object(update_check, 'build_github_opener', return_value=opener), \
|
||||||
|
mock.patch.object(linux_update, '_run_install') as install:
|
||||||
|
error = linux_update.apply_update(kind, 'https://example.invalid/asset',
|
||||||
|
digest='sha256:' + '0' * 64)
|
||||||
|
self.assertIn('SHA-256', error)
|
||||||
|
install.assert_not_called()
|
||||||
|
self.assertEqual(exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||||
|
|
||||||
|
def test_verified_download_is_installed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'TgWsProxy'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
opener = mock.Mock()
|
||||||
|
opener.open.return_value = io.BytesIO(b'new')
|
||||||
|
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||||
|
mock.patch.object(update_check, 'build_github_opener', return_value=opener):
|
||||||
|
self.assertIsNone(linux_update.apply_update(
|
||||||
|
'binary', 'https://example.invalid/asset',
|
||||||
|
digest='sha256:' + hashlib.sha256(b'new').hexdigest(),
|
||||||
|
))
|
||||||
|
self.assertEqual(exe.read_bytes(), b'new')
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||||
|
|
||||||
|
def test_install_timeout_cleans_download_directory(self):
|
||||||
|
holder = {}
|
||||||
|
|
||||||
|
def download(url, dest_dir, **kwargs):
|
||||||
|
holder['directory'] = dest_dir
|
||||||
|
path = dest_dir / 'update.deb'
|
||||||
|
path.write_bytes(b'package')
|
||||||
|
return path
|
||||||
|
|
||||||
|
with mock.patch.object(linux_update, 'download_asset', side_effect=download), \
|
||||||
|
mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)):
|
||||||
|
self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset'))
|
||||||
|
self.assertFalse(holder['directory'].exists())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement')
|
||||||
|
class ElevatedBinaryReplacementTest(unittest.TestCase):
|
||||||
|
def test_replaces_a_running_executable_with_special_characters_in_its_path(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
directory = Path(tmp)
|
||||||
|
exe = directory / 'app $(touch unwanted); тест'
|
||||||
|
src = directory / 'download with spaces'
|
||||||
|
shutil.copy2(shutil.which('sleep'), exe)
|
||||||
|
src.write_bytes(b'new')
|
||||||
|
process = subprocess.Popen([str(exe), '30'])
|
||||||
|
try:
|
||||||
|
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||||
|
cmd = linux_update.replace_binary_cmd(src, exe)
|
||||||
|
self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0)
|
||||||
|
self.assertIsNone(process.poll())
|
||||||
|
self.assertEqual(exe.read_bytes(), b'new')
|
||||||
|
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||||
|
self.assertEqual(set(directory.iterdir()), {exe, src})
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / 'app'
|
||||||
|
exe.write_bytes(b'old')
|
||||||
|
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||||
|
cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe)
|
||||||
|
result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalCommandsEnvTest(unittest.TestCase):
|
||||||
|
def test_commands_use_system_libraries_without_changing_parent_environment(self):
|
||||||
|
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
|
||||||
|
probe = (
|
||||||
|
'import os, sys; '
|
||||||
|
'assert os.environ.get("LD_LIBRARY_PATH", "") == sys.argv[1]; '
|
||||||
|
'assert "_PYI_APPLICATION_HOME_DIR" not in os.environ'
|
||||||
|
)
|
||||||
|
for original in ('/usr/lib', None):
|
||||||
|
for runner, expected in ((linux_update._run_quiet, True),
|
||||||
|
(linux_update._run_install, None)):
|
||||||
|
with self.subTest(original=original, runner=runner.__name__):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(LD_LIBRARY_PATH=bundle, _PYI_APPLICATION_HOME_DIR=bundle)
|
||||||
|
env.pop('LD_LIBRARY_PATH_ORIG', None)
|
||||||
|
if original is not None:
|
||||||
|
env['LD_LIBRARY_PATH_ORIG'] = original
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True), \
|
||||||
|
mock.patch.object(sys, '_MEIPASS', bundle, create=True):
|
||||||
|
self.assertEqual(
|
||||||
|
runner([sys.executable, '-c', probe, original or '']), expected,
|
||||||
|
)
|
||||||
|
self.assertEqual(os.environ['LD_LIBRARY_PATH'], bundle)
|
||||||
|
self.assertEqual(os.environ['_PYI_APPLICATION_HOME_DIR'], bundle)
|
||||||
|
|
||||||
|
|
||||||
|
class RelaunchEnvTest(unittest.TestCase):
|
||||||
|
def _env(self, env, meipass='/tmp/_MEI1'):
|
||||||
|
with mock.patch.dict(linux_update.os.environ, env, clear=True), \
|
||||||
|
mock.patch.object(linux_update.sys, '_MEIPASS', meipass, create=True):
|
||||||
|
return linux_update.relaunch_env()
|
||||||
|
|
||||||
|
def test_bundle_paths_are_dropped_and_originals_restored(self):
|
||||||
|
out = self._env({
|
||||||
|
'_PYI_APPLICATION_HOME_DIR': '/tmp/_MEI1',
|
||||||
|
'_MEIPASS': '/tmp/_MEI1',
|
||||||
|
'LD_LIBRARY_PATH': '/tmp/_MEI1',
|
||||||
|
'LD_LIBRARY_PATH_ORIG': '/usr/lib',
|
||||||
|
'GI_TYPELIB_PATH': '/tmp/_MEI1',
|
||||||
|
'PATH': os.pathsep.join(('/tmp/_MEI1', '/usr/bin')),
|
||||||
|
'HOME': '/home/u',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(out['LD_LIBRARY_PATH'], '/usr/lib')
|
||||||
|
self.assertNotIn('GI_TYPELIB_PATH', out)
|
||||||
|
self.assertEqual(out['PATH'], '/usr/bin')
|
||||||
|
self.assertNotIn('_MEIPASS', out)
|
||||||
|
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', out)
|
||||||
|
self.assertEqual(out['HOME'], '/home/u')
|
||||||
|
|
||||||
|
def test_environment_without_bundle_is_unchanged(self):
|
||||||
|
env = {'PATH': '/usr/bin', 'HOME': '/home/u'}
|
||||||
|
with mock.patch.dict(linux_update.os.environ, env, clear=True):
|
||||||
|
with mock.patch.object(linux_update.sys, '_MEIPASS', None, create=True):
|
||||||
|
out = linux_update.relaunch_env()
|
||||||
|
self.assertEqual(out, env)
|
||||||
|
|
||||||
|
|
||||||
|
class RestartAfterUpdateTest(unittest.TestCase):
|
||||||
|
def test_restart_releases_resources_before_launch_and_preserves_arguments(self):
|
||||||
|
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||||
|
import linux
|
||||||
|
|
||||||
|
events = []
|
||||||
|
exe = str(Path(tempfile.gettempdir()) / 'proxy')
|
||||||
|
with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \
|
||||||
|
mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \
|
||||||
|
mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \
|
||||||
|
mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \
|
||||||
|
mock.patch.object(sys, 'argv', ['proxy', '--portable']), \
|
||||||
|
mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \
|
||||||
|
mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')):
|
||||||
|
linux._restart_after_update()
|
||||||
|
self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit'])
|
||||||
|
self.assertEqual(launch.call_args[0][0], [exe, '--portable'])
|
||||||
|
self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'})
|
||||||
|
|
||||||
|
def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self):
|
||||||
|
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||||
|
import linux
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in (
|
||||||
|
'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error',
|
||||||
|
)}
|
||||||
|
exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit'))
|
||||||
|
stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed')))
|
||||||
|
linux._restart_after_update()
|
||||||
|
mocks['acquire_lock'].assert_called_once_with()
|
||||||
|
mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error'])
|
||||||
|
mocks['_show_error'].assert_called_once()
|
||||||
|
exit_process.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+227
-9
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -6,7 +7,9 @@ from types import SimpleNamespace
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from proxy.config import proxy_config
|
from proxy.config import proxy_config
|
||||||
from proxy.pool import _WsPool
|
from proxy.pool import _CfWorkerPool, _WsPool
|
||||||
|
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
|
||||||
|
from proxy.utils import ws_domains
|
||||||
|
|
||||||
|
|
||||||
class _StopRotation(Exception):
|
class _StopRotation(Exception):
|
||||||
@@ -15,24 +18,38 @@ class _StopRotation(Exception):
|
|||||||
|
|
||||||
def _open_ws():
|
def _open_ws():
|
||||||
transport = SimpleNamespace(is_closing=lambda: False)
|
transport = SimpleNamespace(is_closing=lambda: False)
|
||||||
writer = SimpleNamespace(transport=transport)
|
writer = mock.Mock(transport=transport, drain=mock.AsyncMock(),
|
||||||
return SimpleNamespace(_closed=False, writer=writer)
|
wait_closed=mock.AsyncMock())
|
||||||
|
return RawWebSocket(asyncio.StreamReader(), writer)
|
||||||
|
|
||||||
|
|
||||||
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
|
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):
|
async def test_refills_partially_populated_bucket(self):
|
||||||
pool = _WsPool()
|
pool = _WsPool()
|
||||||
key = (2, False)
|
key = (2, False, False)
|
||||||
pool._idle[key] = deque([
|
pool._idle[key] = deque([
|
||||||
(_open_ws(), time.monotonic()),
|
(_open_ws(), time.monotonic()),
|
||||||
(_open_ws(), time.monotonic()),
|
(_open_ws(), time.monotonic()),
|
||||||
])
|
])
|
||||||
sleep_calls = 0
|
|
||||||
|
|
||||||
async def stop_after_one_iteration(_delay):
|
async def stop_after_one_iteration(_delay):
|
||||||
nonlocal sleep_calls
|
|
||||||
sleep_calls += 1
|
|
||||||
if sleep_calls > 1:
|
|
||||||
raise _StopRotation
|
raise _StopRotation
|
||||||
|
|
||||||
with mock.patch.object(proxy_config, 'pool_size', 4):
|
with mock.patch.object(proxy_config, 'pool_size', 4):
|
||||||
@@ -49,5 +66,206 @@ class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
key, '149.154.167.220', ['example.com'])
|
key, '149.154.167.220', ['example.com'])
|
||||||
|
|
||||||
|
|
||||||
|
class WsPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.pool = _WsPool()
|
||||||
|
self.key = (2, False, False)
|
||||||
|
self.pool.WS_POOL_CHECK_INTERVAL = .01
|
||||||
|
for name, value in [('dc_redirects', {2: '192.0.2.1'}),
|
||||||
|
('pool_size', 1), ('force_test_dc', False)]:
|
||||||
|
patcher = mock.patch.object(proxy_config, name, value)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.addAsyncCleanup(self.pool.close)
|
||||||
|
|
||||||
|
async def wait_for(self, predicate):
|
||||||
|
async def wait():
|
||||||
|
while not predicate():
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
await asyncio.wait_for(wait(), 1)
|
||||||
|
|
||||||
|
async def test_regular_sessions_never_use_media_domain(self):
|
||||||
|
for dc in (2, 4):
|
||||||
|
with self.subTest(dc=dc), mock.patch(
|
||||||
|
'proxy.pool.RawWebSocket.connect',
|
||||||
|
side_effect=WsHandshakeError(302, 'redirect')) as connect:
|
||||||
|
self.assertIsNone(await self.pool._connect_one('192.0.2.1', ws_domains(dc, False)))
|
||||||
|
self.assertEqual([call.args[1] for call in connect.call_args_list],
|
||||||
|
[f'kws{dc}.web.telegram.org'] * 2)
|
||||||
|
|
||||||
|
async def test_media_tries_regular_domain_after_both_modes_fail(self):
|
||||||
|
for error in (asyncio.TimeoutError(), ConnectionResetError(),
|
||||||
|
WsHandshakeError(302, 'redirect'), WsHandshakeError(500, 'error')):
|
||||||
|
with self.subTest(error=type(error)), mock.patch(
|
||||||
|
'proxy.pool.RawWebSocket.connect',
|
||||||
|
side_effect=[error, error, _open_ws()]) as connect:
|
||||||
|
self.assertIsNotNone(await self.pool._connect_one('192.0.2.1', ws_domains(2, True)))
|
||||||
|
self.assertEqual([call.args[1] for call in connect.call_args_list],
|
||||||
|
['kws2-1.web.telegram.org'] * 2 + ['kws2.web.telegram.org'])
|
||||||
|
|
||||||
|
async def test_concurrent_preference_change_does_not_skip_fronting(self):
|
||||||
|
ws = _open_ws()
|
||||||
|
|
||||||
|
async def connect(*args, **kwargs):
|
||||||
|
if kwargs['sni'] is None:
|
||||||
|
self.pool.try_fronting_first = True
|
||||||
|
raise asyncio.TimeoutError()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
with mock.patch('proxy.pool.RawWebSocket.connect', side_effect=connect) as dial:
|
||||||
|
self.assertIs(await self.pool._connect_one('192.0.2.1', ws_domains(2, False)), ws)
|
||||||
|
self.assertEqual(dial.await_count, 2)
|
||||||
|
|
||||||
|
async def test_miss_returns_without_waiting_and_starts_only_one_refill(self):
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def connect(*args):
|
||||||
|
started.set()
|
||||||
|
await asyncio.Future()
|
||||||
|
|
||||||
|
with mock.patch.object(self.pool, '_connect_one', side_effect=connect) as dial:
|
||||||
|
for _ in range(10):
|
||||||
|
self.assertIsNone(await self.pool.get(2, False))
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
self.assertEqual(dial.await_count, 1)
|
||||||
|
await self.pool.close()
|
||||||
|
|
||||||
|
async def test_empty_pool_recovers_in_background_after_backoff(self):
|
||||||
|
ws = _open_ws()
|
||||||
|
self.pool.REFILL_BACKOFF_INITIAL = .02
|
||||||
|
with mock.patch.object(self.pool, '_connect_one',
|
||||||
|
side_effect=[None, ws]) as dial:
|
||||||
|
self.assertIsNone(await self.pool.get(2, False))
|
||||||
|
await self.wait_for(lambda: self.key in self.pool._refill_after)
|
||||||
|
self.assertEqual(dial.await_count, 1)
|
||||||
|
# No new client requests are needed to resume refilling.
|
||||||
|
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
|
||||||
|
self.assertEqual(dial.await_count, 2)
|
||||||
|
self.assertNotIn(self.key, self.pool._refill_failures)
|
||||||
|
await self.pool.close()
|
||||||
|
|
||||||
|
async def test_ready_connection_is_available_before_slower_attempt(self):
|
||||||
|
ready = _open_ws()
|
||||||
|
slow_started = asyncio.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def connect(*args):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
slow_started.set()
|
||||||
|
await asyncio.Future()
|
||||||
|
return ready
|
||||||
|
|
||||||
|
with mock.patch.object(proxy_config, 'pool_size', 2), \
|
||||||
|
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
|
||||||
|
await self.pool.get(2, False)
|
||||||
|
await asyncio.wait_for(slow_started.wait(), 1)
|
||||||
|
await self.wait_for(lambda: bool(self.pool._idle[self.key]))
|
||||||
|
self.assertIs(await self.pool.get(2, False), ready)
|
||||||
|
await self.pool.close()
|
||||||
|
|
||||||
|
async def test_stale_connections_are_skipped(self):
|
||||||
|
for state in ('expired', 'eof', 'exception', 'closed', 'closing'):
|
||||||
|
with self.subTest(state=state):
|
||||||
|
stale, good = _open_ws(), _open_ws()
|
||||||
|
created = time.monotonic()
|
||||||
|
if state == 'expired':
|
||||||
|
created -= self.pool.WS_POOL_MAX_AGE
|
||||||
|
elif state == 'eof':
|
||||||
|
stale.reader.feed_eof()
|
||||||
|
elif state == 'exception':
|
||||||
|
stale.reader.set_exception(ConnectionResetError())
|
||||||
|
elif state == 'closed':
|
||||||
|
stale._closed = True
|
||||||
|
else:
|
||||||
|
stale.writer.transport.is_closing = lambda: True
|
||||||
|
self.pool._idle[self.key] = deque([(stale, created), (good, time.monotonic())])
|
||||||
|
with mock.patch.object(self.pool, '_schedule_refill'):
|
||||||
|
self.assertIs(await self.pool.get(2, False), good)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
async def test_hit_does_not_reset_failed_refill_backoff(self):
|
||||||
|
self.pool._idle[self.key] = deque([(_open_ws(), time.monotonic())])
|
||||||
|
self.pool._refill_failures[self.key] = 3
|
||||||
|
self.pool._refill_after[self.key] = time.monotonic() + 60
|
||||||
|
with mock.patch.object(self.pool, '_schedule_rotation'):
|
||||||
|
self.assertIsNotNone(await self.pool.get(2, False))
|
||||||
|
self.assertEqual(self.pool._refill_failures[self.key], 3)
|
||||||
|
self.assertFalse(self.pool._refilling)
|
||||||
|
|
||||||
|
async def test_zero_size_and_unconfigured_dc_do_not_connect(self):
|
||||||
|
with mock.patch.object(self.pool, '_connect_one') as dial:
|
||||||
|
self.assertIsNone(await self.pool.get(4, False))
|
||||||
|
with mock.patch.object(proxy_config, 'pool_size', 0):
|
||||||
|
await self.pool.warmup()
|
||||||
|
self.assertIsNone(await self.pool.get(2, False))
|
||||||
|
self.assertFalse(self.pool._rotating)
|
||||||
|
self.assertFalse(self.pool._refilling)
|
||||||
|
dial.assert_not_called()
|
||||||
|
|
||||||
|
async def test_test_dc_has_separate_pool_and_path(self):
|
||||||
|
prod = _open_ws()
|
||||||
|
self.pool._idle[self.key] = deque([(prod, time.monotonic())])
|
||||||
|
with mock.patch.object(self.pool, '_connect_one', return_value=_open_ws()) as dial:
|
||||||
|
self.assertIsNone(await self.pool.get(2, False, is_test_dc=True))
|
||||||
|
test_key = (2, False, True)
|
||||||
|
await self.wait_for(lambda: bool(self.pool._idle.get(test_key)))
|
||||||
|
dial.assert_awaited_once_with('192.0.2.1', ['kws2.web.telegram.org'], '/apiws_test')
|
||||||
|
self.assertIs(self.pool._idle[self.key][0][0], prod)
|
||||||
|
await self.pool.close()
|
||||||
|
|
||||||
|
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
|
||||||
|
ready = _open_ws()
|
||||||
|
idle = _open_ws()
|
||||||
|
started = asyncio.Event()
|
||||||
|
self.pool._idle[self.key] = deque([(idle, time.monotonic())])
|
||||||
|
|
||||||
|
async def connect(*args):
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Completion raced with shutdown; it must not leak into the pool.
|
||||||
|
return ready
|
||||||
|
|
||||||
|
with mock.patch.object(proxy_config, 'pool_size', 2), \
|
||||||
|
mock.patch.object(self.pool, '_connect_one', side_effect=connect):
|
||||||
|
self.pool._schedule_refill(self.key, '192.0.2.1', ws_domains(2, False))
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
await self.pool.close()
|
||||||
|
self.assertFalse(self.pool._idle)
|
||||||
|
self.assertFalse(self.pool._rotating)
|
||||||
|
self.assertFalse(self.pool._refilling)
|
||||||
|
ready.writer.close.assert_called_once()
|
||||||
|
idle.writer.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class CfWorkerPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
|
||||||
|
pool = _CfWorkerPool()
|
||||||
|
ready = _open_ws()
|
||||||
|
idle = _open_ws()
|
||||||
|
started = asyncio.Event()
|
||||||
|
pool._idle[2] = deque([(idle, time.monotonic(), 'a.example')])
|
||||||
|
|
||||||
|
async def connect(*args):
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return ready, 'b.example'
|
||||||
|
|
||||||
|
with mock.patch.object(proxy_config, 'pool_size', 1), \
|
||||||
|
mock.patch.object(pool, '_connect_one', side_effect=connect):
|
||||||
|
pool._schedule_refill(4, '192.0.2.4', ['b.example'])
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
await pool.close()
|
||||||
|
self.assertFalse(pool._idle)
|
||||||
|
self.assertFalse(pool._refilling)
|
||||||
|
ready.writer.close.assert_called_once()
|
||||||
|
idle.writer.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
|
from proxy.raw_websocket import RawWebSocket, WsHandshakeError, _xor_mask
|
||||||
|
|
||||||
@@ -35,6 +36,15 @@ def _recv(chunks, cls=RawWebSocket):
|
|||||||
|
|
||||||
|
|
||||||
class XorMaskTest(unittest.TestCase):
|
class XorMaskTest(unittest.TestCase):
|
||||||
|
def test_matches_bytewise_xor_at_fast_path_and_frame_boundaries(self):
|
||||||
|
for length in (0, 1, 2, 3, 4, 5, 125, 126, 127, 255, 511, 512, 513, 514, 515,
|
||||||
|
16384, 65535, 65536, 65537, 1024 * 1024 + 3):
|
||||||
|
data = (bytes(range(256)) * (length // 256 + 1))[:length]
|
||||||
|
for mask in (b'\x00' * 4, b'\xff' * 4, b'\x01\x7f\x80\xff'):
|
||||||
|
with self.subTest(length=length, mask=mask):
|
||||||
|
expected = bytes(value ^ mask[index % 4] for index, value in enumerate(data))
|
||||||
|
self.assertEqual(_xor_mask(data, mask), expected)
|
||||||
|
|
||||||
def test_roundtrip(self):
|
def test_roundtrip(self):
|
||||||
data = bytes(range(256)) * 3
|
data = bytes(range(256)) * 3
|
||||||
mask = b'\x01\x02\x03\x04'
|
mask = b'\x01\x02\x03\x04'
|
||||||
@@ -45,6 +55,21 @@ class XorMaskTest(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class BuildFrameTest(unittest.TestCase):
|
class BuildFrameTest(unittest.TestCase):
|
||||||
|
def test_masked_frame_wire_format_across_all_length_encodings(self):
|
||||||
|
mask = b'\x01\x7f\x80\xff'
|
||||||
|
for length in (1, 125, 126, 511, 512, 513, 65535, 65536, 65537):
|
||||||
|
payload = (bytes(range(256)) * (length // 256 + 1))[:length]
|
||||||
|
with self.subTest(length=length), patch('proxy.raw_websocket.os.urandom', return_value=mask):
|
||||||
|
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, payload, mask=True)
|
||||||
|
if length < 126:
|
||||||
|
header = bytes([0x82, 0x80 | length])
|
||||||
|
elif length < 65536:
|
||||||
|
header = b'\x82\xfe' + length.to_bytes(2, 'big')
|
||||||
|
else:
|
||||||
|
header = b'\x82\xff' + length.to_bytes(8, 'big')
|
||||||
|
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
||||||
|
self.assertEqual(frame, header + mask + masked)
|
||||||
|
|
||||||
def test_short_unmasked_frame(self):
|
def test_short_unmasked_frame(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
|
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
|
||||||
@@ -126,5 +151,38 @@ class HandshakeErrorTest(unittest.TestCase):
|
|||||||
self.assertFalse(WsHandshakeError(code, '').is_redirect)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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,49 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from utils import tray_common
|
||||||
|
|
||||||
|
|
||||||
|
class CheckUpdateAsyncTest(unittest.TestCase):
|
||||||
|
def test_disabled_check_does_not_start_a_thread(self):
|
||||||
|
called = []
|
||||||
|
with mock.patch.object(tray_common.threading, 'Thread') as thread:
|
||||||
|
tray_common.check_update_async(
|
||||||
|
{'check_updates': False}, lambda: False, lambda v, u: called.append(v),
|
||||||
|
)
|
||||||
|
thread.assert_not_called()
|
||||||
|
self.assertEqual(called, [])
|
||||||
|
|
||||||
|
def test_update_is_reported_to_the_handler(self):
|
||||||
|
status = {'has_update': True, 'latest': '2.0.0', 'html_url': 'https://x.invalid/r'}
|
||||||
|
seen = []
|
||||||
|
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
|
||||||
|
mock.patch.object(tray_common.time, 'sleep'), \
|
||||||
|
mock.patch('utils.update_check.run_check'), \
|
||||||
|
mock.patch('utils.update_check.get_status', return_value=status):
|
||||||
|
tray_common.check_update_async(
|
||||||
|
{'check_updates': True}, lambda: False,
|
||||||
|
lambda version, url: seen.append((version, url)),
|
||||||
|
)
|
||||||
|
thread.call_args.kwargs['target']()
|
||||||
|
|
||||||
|
self.assertEqual(seen, [('2.0.0', 'https://x.invalid/r')])
|
||||||
|
|
||||||
|
def test_no_update_means_no_handler_call(self):
|
||||||
|
seen = []
|
||||||
|
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
|
||||||
|
mock.patch.object(tray_common.time, 'sleep'), \
|
||||||
|
mock.patch('utils.update_check.run_check'), \
|
||||||
|
mock.patch('utils.update_check.get_status',
|
||||||
|
return_value={'has_update': False}):
|
||||||
|
tray_common.check_update_async(
|
||||||
|
{'check_updates': True}, lambda: False,
|
||||||
|
lambda version, url: seen.append(version),
|
||||||
|
)
|
||||||
|
thread.call_args.kwargs['target']()
|
||||||
|
|
||||||
|
self.assertEqual(seen, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from http.client import IncompleteRead
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
from utils import update_check
|
||||||
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
|
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
|
||||||
|
|
||||||
|
PAYLOAD = b'payload'
|
||||||
|
DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class ParseVersionTupleTest(unittest.TestCase):
|
class ParseVersionTupleTest(unittest.TestCase):
|
||||||
def test_plain_and_prefixed_versions(self):
|
def test_plain_and_prefixed_versions(self):
|
||||||
@@ -66,5 +81,257 @@ class ExtractAssetsTest(unittest.TestCase):
|
|||||||
self.assertEqual(_extract_assets({}), [])
|
self.assertEqual(_extract_assets({}), [])
|
||||||
|
|
||||||
|
|
||||||
|
class _Response(io.BytesIO):
|
||||||
|
def __init__(self, body, content_length='auto'):
|
||||||
|
super().__init__(body)
|
||||||
|
if content_length == 'auto':
|
||||||
|
content_length = len(body)
|
||||||
|
self.headers = {} if content_length is None else {
|
||||||
|
'Content-Length': str(content_length),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _opener(response):
|
||||||
|
opener = mock.Mock()
|
||||||
|
opener.open.return_value = response
|
||||||
|
return mock.Mock(return_value=opener)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadAssetTest(unittest.TestCase):
|
||||||
|
def _download(self, response, digest=DIGEST):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||||
|
path = update_check.download_asset(
|
||||||
|
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir())
|
||||||
|
finally:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_complete_download_is_kept(self):
|
||||||
|
body, files = self._download(_Response(b'payload'))
|
||||||
|
self.assertEqual(body, b'payload')
|
||||||
|
self.assertEqual(len(files), 1)
|
||||||
|
|
||||||
|
def test_matching_digest_is_accepted(self):
|
||||||
|
digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest()
|
||||||
|
body, _ = self._download(_Response(b'payload'), digest=digest)
|
||||||
|
self.assertEqual(body, b'payload')
|
||||||
|
|
||||||
|
def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
|
||||||
|
for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
|
||||||
|
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener') as opener:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest)
|
||||||
|
opener.assert_not_called()
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||||
|
|
||||||
|
def test_truncated_download_raises_and_leaves_no_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
resp = _Response(b'pay', content_length=None)
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
update_check.download_asset(
|
||||||
|
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST,
|
||||||
|
)
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||||
|
|
||||||
|
def test_digest_mismatch_raises_and_leaves_no_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
resp = _Response(b'payload')
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
update_check.download_asset(
|
||||||
|
'https://example.invalid/a', Path(tmp),
|
||||||
|
suffix='.bin', digest='sha256:' + '0' * 64,
|
||||||
|
)
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||||
|
|
||||||
|
def test_missing_content_length_is_tolerated(self):
|
||||||
|
body, _ = self._download(_Response(b'payload', content_length=None))
|
||||||
|
self.assertEqual(body, b'payload')
|
||||||
|
|
||||||
|
def test_same_length_corruption_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(OSError, 'SHA-256'):
|
||||||
|
self._download(_Response(b'payloae'))
|
||||||
|
|
||||||
|
def test_uppercase_checksum_is_accepted(self):
|
||||||
|
body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper())
|
||||||
|
self.assertEqual(body, PAYLOAD)
|
||||||
|
|
||||||
|
def test_stream_failure_removes_partial_file(self):
|
||||||
|
for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)):
|
||||||
|
with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.read.side_effect = [b'pay', error]
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||||
|
with self.assertRaises(type(error)):
|
||||||
|
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST)
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||||
|
|
||||||
|
def test_hash_covers_every_chunk_and_read_timeout_is_set(self):
|
||||||
|
body = PAYLOAD * 20000
|
||||||
|
opener = _opener(_Response(body))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||||
|
path = update_check.download_asset(
|
||||||
|
'https://example.invalid/a', Path(tmp),
|
||||||
|
digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7,
|
||||||
|
)
|
||||||
|
self.assertEqual(path.read_bytes(), body)
|
||||||
|
opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7)
|
||||||
|
|
||||||
|
|
||||||
|
class FindAssetTest(unittest.TestCase):
|
||||||
|
def test_returns_asset_with_digest(self):
|
||||||
|
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}]
|
||||||
|
with mock.patch.dict(update_check._state, {'assets': assets}):
|
||||||
|
self.assertEqual(update_check.find_asset('a.deb'), assets[0])
|
||||||
|
self.assertIsNone(update_check.find_asset('b.deb'))
|
||||||
|
|
||||||
|
def test_ignores_asset_without_url(self):
|
||||||
|
with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
|
||||||
|
self.assertIsNone(update_check.find_asset('a.deb'))
|
||||||
|
|
||||||
|
def test_assets_without_a_valid_checksum_require_manual_installation(self):
|
||||||
|
for digest in ('', 'sha256:ab', 'md5:' + '0' * 32):
|
||||||
|
asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest}
|
||||||
|
with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}):
|
||||||
|
self.assertIsNone(update_check.find_asset('a.exe'))
|
||||||
|
|
||||||
|
|
||||||
|
class RunCheckTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.patch = mock.patch.object(update_check, '_state', update_check.get_status())
|
||||||
|
self.patch.start()
|
||||||
|
self.addCleanup(self.patch.stop)
|
||||||
|
|
||||||
|
def _check(self, data, current='1.11.1'):
|
||||||
|
with mock.patch.object(update_check, 'fetch_latest_release', return_value=data):
|
||||||
|
update_check.run_check(current)
|
||||||
|
return update_check.get_status()
|
||||||
|
|
||||||
|
def test_versions_are_compared_and_assets_published_together(self):
|
||||||
|
release = {'tag_name': ' v2.0 ', 'assets': [{
|
||||||
|
'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST,
|
||||||
|
}]}
|
||||||
|
for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)):
|
||||||
|
with self.subTest(current=current):
|
||||||
|
state = self._check(release, current)
|
||||||
|
self.assertTrue(state['checked'])
|
||||||
|
self.assertIsNone(state['error'])
|
||||||
|
self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead))
|
||||||
|
self.assertEqual(state['latest'], '2.0')
|
||||||
|
self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL)
|
||||||
|
self.assertEqual(state['assets'][0]['digest'], DIGEST)
|
||||||
|
|
||||||
|
def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self):
|
||||||
|
for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'},
|
||||||
|
{'tag_name': 'v2.0', 'assets': 'invalid'}):
|
||||||
|
with self.subTest(data=data):
|
||||||
|
state = self._check(data)
|
||||||
|
self.assertTrue(state['error'])
|
||||||
|
self.assertFalse(state['has_update'])
|
||||||
|
self.assertIsNone(state['latest'])
|
||||||
|
self.assertEqual(state['assets'], [])
|
||||||
|
|
||||||
|
def test_release_tag_suffixes_keep_the_existing_version_comparison(self):
|
||||||
|
state = self._check({'tag_name': 'v1.12.0-hotfix'})
|
||||||
|
self.assertIsNone(state['error'])
|
||||||
|
self.assertTrue(state['has_update'])
|
||||||
|
self.assertEqual(state['latest'], '1.12.0-hotfix')
|
||||||
|
|
||||||
|
def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self):
|
||||||
|
errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None),
|
||||||
|
TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json'))
|
||||||
|
for error in errors:
|
||||||
|
with self.subTest(error=error):
|
||||||
|
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||||
|
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||||
|
}]})
|
||||||
|
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error):
|
||||||
|
update_check.run_check('1.11.1')
|
||||||
|
state = update_check.get_status()
|
||||||
|
self.assertTrue(state['error'])
|
||||||
|
self.assertEqual(state['assets'], [])
|
||||||
|
self.assertFalse(state['has_update'])
|
||||||
|
self.assertIsNone(self._check({'tag_name': 'v2.1'})['error'])
|
||||||
|
|
||||||
|
def test_readers_do_not_see_partially_updated_status(self):
|
||||||
|
previous = self._check({'tag_name': 'v1.11.1'})
|
||||||
|
started, finish = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def fetch():
|
||||||
|
started.set()
|
||||||
|
finish.wait(timeout=5)
|
||||||
|
return {'tag_name': 'v2.0'}
|
||||||
|
|
||||||
|
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch):
|
||||||
|
worker = threading.Thread(target=update_check.run_check, args=('1.11.1',))
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(started.wait(timeout=5))
|
||||||
|
self.assertEqual(update_check.get_status(), previous)
|
||||||
|
finally:
|
||||||
|
finish.set()
|
||||||
|
worker.join(timeout=5)
|
||||||
|
self.assertEqual(update_check.get_status()['latest'], '2.0')
|
||||||
|
|
||||||
|
def test_asset_and_status_snapshots_cannot_modify_internal_state(self):
|
||||||
|
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||||
|
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||||
|
}]})
|
||||||
|
asset = update_check.find_asset('a.exe')
|
||||||
|
asset['digest'] = 'bad'
|
||||||
|
snapshot = update_check.get_status()
|
||||||
|
snapshot['assets'][0]['digest'] = 'bad'
|
||||||
|
snapshot['assets'].clear()
|
||||||
|
self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST)
|
||||||
|
|
||||||
|
def test_old_cache_is_ignored_and_no_new_cache_is_written(self):
|
||||||
|
from utils import tray_common
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / '.update_check_cache.json'
|
||||||
|
old = '{"last_attempt_at": "invalid", "assets": "invalid"}'
|
||||||
|
path.write_text(old, encoding='utf-8')
|
||||||
|
with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)):
|
||||||
|
self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update'])
|
||||||
|
self.assertEqual(path.read_text(encoding='utf-8'), old)
|
||||||
|
self.assertEqual(list(Path(tmp).iterdir()), [path])
|
||||||
|
|
||||||
|
def test_fetch_is_one_request_without_etag_and_with_timeout(self):
|
||||||
|
opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode()))
|
||||||
|
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||||
|
self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0')
|
||||||
|
args, kwargs = opener.return_value.open.call_args
|
||||||
|
self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API)
|
||||||
|
self.assertFalse(args[0].has_header('If-none-match'))
|
||||||
|
self.assertEqual(kwargs['timeout'], 7)
|
||||||
|
|
||||||
|
|
||||||
|
class RelaunchEnvTest(unittest.TestCase):
|
||||||
|
def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self):
|
||||||
|
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
|
||||||
|
env = {
|
||||||
|
'_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe',
|
||||||
|
'_MEIPASS': bundle, '_MEIPASS2': bundle,
|
||||||
|
'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')),
|
||||||
|
}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True):
|
||||||
|
result = update_check.relaunch_env()
|
||||||
|
self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from utils import update_check, windows_update
|
||||||
|
|
||||||
|
|
||||||
|
class AssetNameTest(unittest.TestCase):
|
||||||
|
def test_keeps_the_running_build_even_when_renamed_or_emulated(self):
|
||||||
|
builds = (
|
||||||
|
('win-amd64', (3, 11), 'TgWsProxy_windows.exe'),
|
||||||
|
('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'),
|
||||||
|
('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'),
|
||||||
|
('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'),
|
||||||
|
('unknown', (3, 11), None),
|
||||||
|
)
|
||||||
|
for platform, version, expected in builds:
|
||||||
|
with self.subTest(platform=platform, version=version), \
|
||||||
|
mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \
|
||||||
|
mock.patch.object(sys, 'version_info', version), \
|
||||||
|
mock.patch.object(sys, 'executable', 'renamed.exe'):
|
||||||
|
self.assertEqual(windows_update.asset_name(), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyUpdateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
stack = ExitStack()
|
||||||
|
self.addCleanup(stack.close)
|
||||||
|
directory = stack.enter_context(tempfile.TemporaryDirectory())
|
||||||
|
self.exe = Path(directory) / 'renamed app.exe'
|
||||||
|
self.exe.write_bytes(b'old')
|
||||||
|
self.backup = self.exe.with_name('renamed app_oldtgws.exe')
|
||||||
|
self.opener = mock.Mock()
|
||||||
|
self.opener.open.return_value = io.BytesIO(b'new')
|
||||||
|
self.asset = {'url': 'https://example.invalid/new.exe',
|
||||||
|
'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()}
|
||||||
|
stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe)))
|
||||||
|
stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable']))
|
||||||
|
stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True))
|
||||||
|
stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener))
|
||||||
|
stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True))
|
||||||
|
stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True))
|
||||||
|
self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen'))
|
||||||
|
self.prepare = mock.Mock()
|
||||||
|
|
||||||
|
def test_verified_update_keeps_legacy_backup_and_launch_arguments(self):
|
||||||
|
def prepare():
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||||
|
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||||
|
self.launch.assert_not_called()
|
||||||
|
|
||||||
|
windows_update.apply_update(self.asset, prepare)
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||||
|
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||||
|
self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup})
|
||||||
|
self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable'])
|
||||||
|
self.assertEqual(self.launch.call_args[1]['creationflags'], 520)
|
||||||
|
|
||||||
|
def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self):
|
||||||
|
for digest in ('', 'sha256:' + '0' * 64):
|
||||||
|
with self.subTest(digest=digest):
|
||||||
|
self.asset['digest'] = digest
|
||||||
|
with self.assertRaises((OSError, ValueError)):
|
||||||
|
windows_update.apply_update(self.asset, self.prepare)
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||||
|
self.prepare.assert_not_called()
|
||||||
|
self.launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_launch_failure_restores_the_old_executable(self):
|
||||||
|
self.launch.side_effect = OSError('launch failed')
|
||||||
|
with self.assertRaisesRegex(OSError, 'launch failed'):
|
||||||
|
windows_update.apply_update(self.asset, self.prepare)
|
||||||
|
self.prepare.assert_called_once_with()
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||||
|
|
||||||
|
def test_rename_failure_leaves_current_executable_and_cleans_download(self):
|
||||||
|
with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')):
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
windows_update.apply_update(self.asset, self.prepare)
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||||
|
self.prepare.assert_not_called()
|
||||||
|
self.launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_replacement_failure_rolls_back_and_cleans_download(self):
|
||||||
|
replace = Path.replace
|
||||||
|
|
||||||
|
def fail_download_move(path, target):
|
||||||
|
if path.suffix == '.tmp':
|
||||||
|
raise PermissionError('move failed')
|
||||||
|
return replace(path, target)
|
||||||
|
|
||||||
|
with mock.patch.object(Path, 'replace', fail_download_move):
|
||||||
|
with self.assertRaisesRegex(PermissionError, 'move failed'):
|
||||||
|
windows_update.apply_update(self.asset, self.prepare)
|
||||||
|
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||||
|
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||||
|
self.prepare.assert_not_called()
|
||||||
|
self.launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self):
|
||||||
|
dll = mock.Mock()
|
||||||
|
with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \
|
||||||
|
mock.patch.object(windows_update.ctypes, 'windll', dll, create=True):
|
||||||
|
windows_update.apply_update(self.asset, self.prepare)
|
||||||
|
self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1')
|
||||||
|
calls = dll.kernel32.SetDllDirectoryW.call_args_list
|
||||||
|
self.assertEqual(calls[0], mock.call(None))
|
||||||
|
self.assertEqual(calls[1].args[0].value, 'old-bundle')
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration')
|
||||||
|
class UpdateFlowTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import windows
|
||||||
|
|
||||||
|
self.windows = windows
|
||||||
|
|
||||||
|
def test_fallback_dialog_actually_installs_the_selected_asset(self):
|
||||||
|
app = self.windows
|
||||||
|
asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64}
|
||||||
|
with mock.patch.object(app, 'ctk', None), \
|
||||||
|
mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \
|
||||||
|
mock.patch.object(app, '_perform_update') as update:
|
||||||
|
app.update_ctk_form('update', asset=asset)
|
||||||
|
update.assert_called_once_with(asset)
|
||||||
|
|
||||||
|
def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self):
|
||||||
|
app = self.windows
|
||||||
|
|
||||||
|
def fail(asset, before_restart):
|
||||||
|
before_restart()
|
||||||
|
raise OSError('launch failed')
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in (
|
||||||
|
'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex',
|
||||||
|
'_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error',
|
||||||
|
)}
|
||||||
|
exit_process = stack.enter_context(mock.patch.object(app.os, '_exit'))
|
||||||
|
mocks['apply_update'].side_effect = fail
|
||||||
|
mocks['_acquire_win_mutex'].return_value = None
|
||||||
|
app._perform_update({'url': 'asset', 'digest': 'digest'})
|
||||||
|
mocks['stop_proxy'].assert_called_once_with()
|
||||||
|
mocks['release_lock'].assert_called_once_with()
|
||||||
|
mocks['_release_win_mutex'].assert_called_once_with()
|
||||||
|
mocks['acquire_lock'].assert_called_once_with()
|
||||||
|
mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error'])
|
||||||
|
mocks['_show_error'].assert_called_once()
|
||||||
|
exit_process.assert_not_called()
|
||||||
|
|
||||||
|
def test_download_failure_leaves_proxy_and_locks_running(self):
|
||||||
|
app = self.windows
|
||||||
|
with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \
|
||||||
|
mock.patch.object(app, 'stop_proxy') as stop, \
|
||||||
|
mock.patch.object(app, 'release_lock') as unlock, \
|
||||||
|
mock.patch.object(app, '_show_error'), \
|
||||||
|
mock.patch.object(app.os, '_exit') as exit_process:
|
||||||
|
app._perform_update({})
|
||||||
|
stop.assert_not_called()
|
||||||
|
unlock.assert_not_called()
|
||||||
|
exit_process.assert_not_called()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
|
||||||
|
log = logging.getLogger("tg-ws-tray")
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTask:
|
||||||
|
def __init__(self, owner):
|
||||||
|
self.owner = owner
|
||||||
|
self.running = False
|
||||||
|
self.closed = False
|
||||||
|
self.pending = None
|
||||||
|
owner.bind("<Destroy>", self._on_destroy, add="+")
|
||||||
|
|
||||||
|
def _on_destroy(self, event):
|
||||||
|
if event.widget is self.owner:
|
||||||
|
self.closed = True
|
||||||
|
if self.pending is not None:
|
||||||
|
self.owner.after_cancel(self.pending)
|
||||||
|
self.pending = None
|
||||||
|
|
||||||
|
def start(self, work, complete, finish):
|
||||||
|
if self.running or self.closed:
|
||||||
|
return
|
||||||
|
self.running = True
|
||||||
|
results = queue.Queue()
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
results.put((True, work()))
|
||||||
|
except Exception:
|
||||||
|
log.exception("Background task failed")
|
||||||
|
results.put((False, None))
|
||||||
|
|
||||||
|
def poll():
|
||||||
|
self.pending = None
|
||||||
|
if self.closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
success, result = results.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
self.pending = self.owner.after(50, poll)
|
||||||
|
return
|
||||||
|
self.running = False
|
||||||
|
try:
|
||||||
|
if success:
|
||||||
|
complete(result)
|
||||||
|
finally:
|
||||||
|
if not self.closed:
|
||||||
|
finish()
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True, name="ui-background-task").start()
|
||||||
|
self.pending = self.owner.after(50, poll)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -75,6 +75,18 @@ def create_ctk_toplevel(
|
|||||||
after_create: Optional[Callable[[Any], None]] = None,
|
after_create: Optional[Callable[[Any], None]] = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
root = ctk.CTkToplevel()
|
root = ctk.CTkToplevel()
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
def handle_ctrl_shortcut(event: Any) -> Optional[str]:
|
||||||
|
shortcuts = {65: ("a", "<<SelectAll>>"), 67: ("c", "<<Copy>>"), 86: ("v", "<<Paste>>")}
|
||||||
|
shortcut = shortcuts.get(event.keycode)
|
||||||
|
if shortcut and event.keysym.lower() != shortcut[0] and not event.state & 0x20000:
|
||||||
|
event.widget.event_generate(shortcut[1])
|
||||||
|
return "break"
|
||||||
|
return None
|
||||||
|
|
||||||
|
root.bind("<Control-KeyPress>", handle_ctrl_shortcut, add="+")
|
||||||
|
|
||||||
root.title(title)
|
root.title(title)
|
||||||
root.resizable(False, False)
|
root.resizable(False, False)
|
||||||
center_ctk_geometry(root, width, height)
|
center_ctk_geometry(root, width, height)
|
||||||
|
|||||||
+11
-926
@@ -1,306 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from typing import Any, Callable, Tuple
|
||||||
import os
|
|
||||||
import webbrowser
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
|
from proxy import get_link_host
|
||||||
from proxy.balancer import balancer
|
from ui.ctk_controls import create_checkbox
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
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.ctk_theme import (
|
from ui.settings_form import (
|
||||||
FIRST_RUN_FRAME_PAD,
|
TrayConfigFormWidgets as TrayConfigFormWidgets,
|
||||||
CtkTheme,
|
install_tray_config_form as install_tray_config_form,
|
||||||
main_content_frame,
|
validate_config_form as validate_config_form,
|
||||||
)
|
)
|
||||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
|
||||||
from ui.i18n import (
|
|
||||||
label_from_language,
|
|
||||||
language_from_label,
|
|
||||||
language_option_labels,
|
|
||||||
set_language,
|
|
||||||
t,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
|
||||||
|
|
||||||
|
|
||||||
def _get_doc_url(doc_name: str) -> str:
|
|
||||||
from ui.i18n import get_language
|
|
||||||
lang = get_language().value
|
|
||||||
lang_folder = "EN" if lang == "en" else "RU"
|
|
||||||
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
|
|
||||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
|
||||||
_CFWORKER_TEST_DST = {
|
|
||||||
1: '149.154.175.50',
|
|
||||||
2: '149.154.167.51',
|
|
||||||
3: '149.154.175.100',
|
|
||||||
4: '149.154.167.91',
|
|
||||||
5: '149.154.171.5',
|
|
||||||
203: '91.105.192.100',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
|
|
||||||
import base64
|
|
||||||
from contextlib import nullcontext
|
|
||||||
import ssl
|
|
||||||
import certifi
|
|
||||||
import socket as _socket
|
|
||||||
|
|
||||||
ctx = ssl.create_default_context(cafile=certifi.where()) if secure else None
|
|
||||||
port = 443 if secure else 80
|
|
||||||
results = {}
|
|
||||||
for dc, connect_host, sni_host, req_host, path in cases:
|
|
||||||
try:
|
|
||||||
with _socket.create_connection((connect_host, port), timeout=5) as raw:
|
|
||||||
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
|
|
||||||
if secure else nullcontext(raw))
|
|
||||||
with connection as ssock:
|
|
||||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
|
||||||
req = (
|
|
||||||
f"GET {path} HTTP/1.1\r\n"
|
|
||||||
f"Host: {req_host}\r\n"
|
|
||||||
f"Upgrade: websocket\r\n"
|
|
||||||
f"Connection: Upgrade\r\n"
|
|
||||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
|
||||||
f"Sec-WebSocket-Version: 13\r\n"
|
|
||||||
f"Sec-WebSocket-Protocol: binary\r\n"
|
|
||||||
f"\r\n"
|
|
||||||
).encode()
|
|
||||||
ssock.sendall(req)
|
|
||||||
ssock.settimeout(5)
|
|
||||||
buf = b""
|
|
||||||
while b"\r\n\r\n" not in buf:
|
|
||||||
chunk = ssock.recv(512)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buf += chunk
|
|
||||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
|
||||||
if "101" in first:
|
|
||||||
results[dc] = True
|
|
||||||
else:
|
|
||||||
results[dc] = first or t("connectivity.no_response")
|
|
||||||
ssock.close()
|
|
||||||
raw.close()
|
|
||||||
except _socket.timeout:
|
|
||||||
results[dc] = t("connectivity.timeout")
|
|
||||||
except OSError as exc:
|
|
||||||
msg = str(exc)
|
|
||||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
|
|
||||||
cases = []
|
|
||||||
for dc in _CFPROXY_TEST_DCS:
|
|
||||||
host = f"kws{dc}.{domain}"
|
|
||||||
cases.append((dc, host, host, host, "/apiws"))
|
|
||||||
return _run_connectivity_test(cases, secure=secure)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
|
|
||||||
cases = []
|
|
||||||
for dc in _CFPROXY_TEST_DCS:
|
|
||||||
dst = _CFWORKER_TEST_DST[dc]
|
|
||||||
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
|
||||||
cases.append((dc, domain, domain, domain, path))
|
|
||||||
return _run_connectivity_test(cases, secure=secure)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
|
|
||||||
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
|
|
||||||
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
|
|
||||||
merged: dict = {}
|
|
||||||
best_domain = None
|
|
||||||
for domain in reversed(domains):
|
|
||||||
res = _run_cfproxy_connectivity_test(domain, secure=secure)
|
|
||||||
if all(v is True for v in res.values()):
|
|
||||||
return domain, res
|
|
||||||
for dc, v in res.items():
|
|
||||||
if v is True:
|
|
||||||
merged[dc] = True
|
|
||||||
best_domain = domain
|
|
||||||
elif dc not in merged:
|
|
||||||
merged[dc] = v
|
|
||||||
return best_domain, merged
|
|
||||||
|
|
||||||
|
|
||||||
def _show_connectivity_results(title_base: str, results: dict,
|
|
||||||
domain: str = '', label_prefix: str = 'DC',
|
|
||||||
auto_mode: bool = False,
|
|
||||||
unavailable_message: str = '') -> None:
|
|
||||||
import tkinter as _tk
|
|
||||||
from tkinter import messagebox as _mb
|
|
||||||
|
|
||||||
ok = [dc for dc, v in results.items() if v is True]
|
|
||||||
total = len(_CFPROXY_TEST_DCS)
|
|
||||||
if auto_mode:
|
|
||||||
if domain:
|
|
||||||
title = t("connectivity.available", title=title_base)
|
|
||||||
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
|
|
||||||
else:
|
|
||||||
title = t("connectivity.unavailable", title=title_base)
|
|
||||||
msg = unavailable_message
|
|
||||||
else:
|
|
||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
|
||||||
if len(ok) == total:
|
|
||||||
title = t("connectivity.all_ok", title=title_base)
|
|
||||||
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
|
|
||||||
elif not ok:
|
|
||||||
title = t("connectivity.unavailable", title=title_base)
|
|
||||||
errors = "\n".join(
|
|
||||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
|
||||||
for dc, v in fail
|
|
||||||
)
|
|
||||||
msg = t("connectivity.none_ok", domain=domain, errors=errors)
|
|
||||||
else:
|
|
||||||
title = t("connectivity.partial", title=title_base)
|
|
||||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
|
||||||
fail_list = "\n".join(
|
|
||||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
|
|
||||||
for dc, v in fail
|
|
||||||
)
|
|
||||||
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
|
||||||
|
|
||||||
root = _tk.Tk()
|
|
||||||
root.withdraw()
|
|
||||||
try:
|
|
||||||
root.attributes("-topmost", True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_mb.showinfo(title, msg, parent=root)
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
|
||||||
label_prefix: str = 'DC') -> None:
|
|
||||||
import tkinter as _tk
|
|
||||||
from tkinter import messagebox as _mb
|
|
||||||
|
|
||||||
total = len(_CFPROXY_TEST_DCS)
|
|
||||||
all_ok = True
|
|
||||||
any_ok = False
|
|
||||||
blocks = []
|
|
||||||
for domain, results in per_domain.items():
|
|
||||||
ok = [dc for dc, v in results.items() if v is True]
|
|
||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
|
||||||
if len(ok) == total:
|
|
||||||
any_ok = True
|
|
||||||
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
|
|
||||||
elif not ok:
|
|
||||||
all_ok = False
|
|
||||||
blocks.append(t("connectivity.multi_fail", domain=domain))
|
|
||||||
else:
|
|
||||||
all_ok = False
|
|
||||||
any_ok = True
|
|
||||||
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
|
|
||||||
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
|
|
||||||
blocks.append(
|
|
||||||
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
if all_ok:
|
|
||||||
title = t("connectivity.all_ok", title=title_base)
|
|
||||||
elif any_ok:
|
|
||||||
title = t("connectivity.partial", title=title_base)
|
|
||||||
else:
|
|
||||||
title = t("connectivity.unavailable", title=title_base)
|
|
||||||
msg = "\n\n".join(blocks)
|
|
||||||
|
|
||||||
root = _tk.Tk()
|
|
||||||
root.withdraw()
|
|
||||||
try:
|
|
||||||
root.attributes("-topmost", True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_mb.showinfo(title, msg, parent=root)
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
_INNER_W = 396
|
|
||||||
|
|
||||||
_APPEARANCE_KEYS = ("auto", "light", "dark")
|
|
||||||
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
|
||||||
|
|
||||||
|
|
||||||
def _appearance_options() -> List[str]:
|
|
||||||
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
|
|
||||||
|
|
||||||
|
|
||||||
def _appearance_from_cfg(value: str) -> str:
|
|
||||||
if value in _APPEARANCE_KEYS:
|
|
||||||
return t(f"appearance.{value}")
|
|
||||||
return t("appearance.auto")
|
|
||||||
|
|
||||||
|
|
||||||
def _appearance_to_cfg(label: str) -> str:
|
|
||||||
for key in _APPEARANCE_KEYS:
|
|
||||||
if t(f"appearance.{key}") == label:
|
|
||||||
return key
|
|
||||||
return "auto"
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
|
|
||||||
combo.configure(values=[label for _, label in language_option_labels()])
|
|
||||||
var.set(label_from_language(cfg_value))
|
|
||||||
|
|
||||||
|
|
||||||
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
|
|
||||||
opts = {
|
|
||||||
"font": (theme.ui_font_family, 13), "corner_radius": radius,
|
|
||||||
"fg_color": theme.bg, "border_color": theme.field_border,
|
|
||||||
"border_width": 1, "text_color": theme.text_primary,
|
|
||||||
}
|
|
||||||
if var is not None:
|
|
||||||
opts["textvariable"] = var
|
|
||||||
if width:
|
|
||||||
opts["width"] = width
|
|
||||||
opts["height"] = height
|
|
||||||
opts.update(kw)
|
|
||||||
return ctk.CTkEntry(parent, **opts)
|
|
||||||
|
|
||||||
|
|
||||||
def _checkbox(ctk, parent, theme, text, variable):
|
|
||||||
return ctk.CTkCheckBox(
|
|
||||||
parent, text=text, variable=variable,
|
|
||||||
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
corner_radius=6, border_width=2, border_color=theme.field_border,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
|
|
||||||
weight = "bold" if bold else "normal"
|
|
||||||
return ctk.CTkLabel(
|
|
||||||
parent, text=text,
|
|
||||||
font=(theme.ui_font_family, size, weight),
|
|
||||||
text_color=theme.text_secondary if secondary else theme.text_primary,
|
|
||||||
anchor="w", **kw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
|
|
||||||
col = ctk.CTkFrame(parent, fg_color="transparent")
|
|
||||||
lbl = _label(ctk, col, theme, label_text)
|
|
||||||
lbl.pack(anchor="w", pady=(0, 2))
|
|
||||||
var = ctk.StringVar(value=str(value))
|
|
||||||
ent = _entry(ctk, col, theme, var=var, width=width)
|
|
||||||
if pack_fill:
|
|
||||||
ent.pack(fill="x")
|
|
||||||
else:
|
|
||||||
ent.pack(anchor="w")
|
|
||||||
if tip:
|
|
||||||
attach_tooltip_to_widgets([lbl, ent, col], tip)
|
|
||||||
return col, var
|
|
||||||
|
|
||||||
|
|
||||||
def tray_settings_scroll_and_footer(
|
def tray_settings_scroll_and_footer(
|
||||||
@@ -325,632 +36,6 @@ def tray_settings_scroll_and_footer(
|
|||||||
return scroll, footer
|
return scroll, footer
|
||||||
|
|
||||||
|
|
||||||
def _config_section(
|
|
||||||
ctk: Any,
|
|
||||||
parent: Any,
|
|
||||||
theme: CtkTheme,
|
|
||||||
title: str,
|
|
||||||
*,
|
|
||||||
bottom_spacer: int = 6,
|
|
||||||
) -> Any:
|
|
||||||
wrap = ctk.CTkFrame(parent, fg_color="transparent")
|
|
||||||
wrap.pack(fill="x", pady=(0, bottom_spacer))
|
|
||||||
_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
|
|
||||||
card = ctk.CTkFrame(
|
|
||||||
wrap, fg_color=theme.field_bg, corner_radius=10,
|
|
||||||
border_width=1, border_color=theme.field_border,
|
|
||||||
)
|
|
||||||
card.pack(fill="x")
|
|
||||||
inner = ctk.CTkFrame(card, fg_color="transparent")
|
|
||||||
inner.pack(fill="x", padx=10, pady=8)
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TrayConfigFormWidgets:
|
|
||||||
host_var: Any
|
|
||||||
port_var: Any
|
|
||||||
secret_var: Any
|
|
||||||
dc_textbox: Any
|
|
||||||
verbose_var: Any
|
|
||||||
no_secure_var: Any
|
|
||||||
adv_entries: List[Any]
|
|
||||||
adv_keys: Tuple[str, ...]
|
|
||||||
autostart_var: Optional[Any]
|
|
||||||
check_updates_var: Optional[Any]
|
|
||||||
cfproxy_var: Optional[Any] = None
|
|
||||||
cfproxy_user_domain_enabled_var: Optional[Any] = None
|
|
||||||
cfproxy_user_domain_var: Optional[Any] = None
|
|
||||||
cfproxy_worker_enabled_var: Optional[Any] = None
|
|
||||||
cfproxy_worker_domain_var: Optional[Any] = None
|
|
||||||
appearance_var: Optional[Any] = None
|
|
||||||
language_var: Optional[Any] = None
|
|
||||||
|
|
||||||
|
|
||||||
def install_tray_config_form(
|
|
||||||
ctk: Any,
|
|
||||||
frame: Any,
|
|
||||||
theme: CtkTheme,
|
|
||||||
cfg: dict,
|
|
||||||
default_config: dict,
|
|
||||||
*,
|
|
||||||
show_autostart: bool = False,
|
|
||||||
autostart_value: bool = False,
|
|
||||||
on_language_change: Optional[Callable[[], None]] = None,
|
|
||||||
on_update_click: Optional[Callable[[], None]] = None,
|
|
||||||
) -> TrayConfigFormWidgets:
|
|
||||||
lang_cfg = cfg.get("language", default_config["language"])
|
|
||||||
set_language(lang_cfg)
|
|
||||||
|
|
||||||
header = ctk.CTkFrame(frame, fg_color="transparent")
|
|
||||||
header.pack(fill="x", pady=(0, 2))
|
|
||||||
ctk.CTkLabel(
|
|
||||||
header, text=t("settings.title"),
|
|
||||||
font=(theme.ui_font_family, 17, "bold"),
|
|
||||||
text_color=theme.text_primary, anchor="w",
|
|
||||||
).pack(side="left")
|
|
||||||
ctk.CTkLabel(
|
|
||||||
header, text=f"v{__version__}",
|
|
||||||
font=(theme.ui_font_family, 12),
|
|
||||||
text_color=theme.text_secondary, anchor="e",
|
|
||||||
).pack(side="right", padx=(4, 0))
|
|
||||||
|
|
||||||
appearance_var = ctk.StringVar(
|
|
||||||
value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_appearance_change(choice: str) -> None:
|
|
||||||
cfg_val = _appearance_to_cfg(choice)
|
|
||||||
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
|
||||||
cfg["appearance"] = cfg_val
|
|
||||||
|
|
||||||
ctk.CTkButton(
|
|
||||||
header, text="Donate ♥", width=90, height=28,
|
|
||||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
|
||||||
fg_color="#22c55e", hover_color="#16a34a",
|
|
||||||
text_color="#ffffff", border_width=0,
|
|
||||||
command=lambda: (
|
|
||||||
header.winfo_toplevel().iconify(),
|
|
||||||
webbrowser.open(_get_doc_url("Funding")),
|
|
||||||
),
|
|
||||||
).pack(side="right", padx=(0, 6))
|
|
||||||
|
|
||||||
ui_inner = _config_section(ctk, frame, theme, t("section.interface"))
|
|
||||||
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
|
|
||||||
ui_row.pack(fill="x")
|
|
||||||
|
|
||||||
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
|
||||||
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
|
||||||
|
|
||||||
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
|
||||||
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
|
||||||
|
|
||||||
language_var = ctk.StringVar(value=label_from_language(lang_cfg))
|
|
||||||
_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
|
|
||||||
anchor="w", pady=(0, 2)
|
|
||||||
)
|
|
||||||
language_combo = ctk.CTkComboBox(
|
|
||||||
lang_col,
|
|
||||||
values=[label for _, label in language_option_labels()],
|
|
||||||
variable=language_var,
|
|
||||||
height=32,
|
|
||||||
font=(theme.ui_font_family, 12),
|
|
||||||
text_color=theme.text_primary,
|
|
||||||
fg_color=theme.bg,
|
|
||||||
border_color=theme.field_border,
|
|
||||||
button_color=theme.field_border,
|
|
||||||
button_hover_color=theme.text_secondary,
|
|
||||||
dropdown_fg_color=theme.field_bg,
|
|
||||||
dropdown_text_color=theme.text_primary,
|
|
||||||
dropdown_hover_color=theme.field_border,
|
|
||||||
corner_radius=8,
|
|
||||||
state="readonly",
|
|
||||||
)
|
|
||||||
language_combo.pack(fill="x")
|
|
||||||
_sync_language_combobox(language_combo, language_var, lang_cfg)
|
|
||||||
|
|
||||||
_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
|
|
||||||
anchor="w", pady=(0, 2)
|
|
||||||
)
|
|
||||||
theme_combo = ctk.CTkComboBox(
|
|
||||||
theme_col,
|
|
||||||
values=_appearance_options(),
|
|
||||||
variable=appearance_var,
|
|
||||||
height=32,
|
|
||||||
font=(theme.ui_font_family, 12),
|
|
||||||
text_color=theme.text_primary,
|
|
||||||
fg_color=theme.bg,
|
|
||||||
border_color=theme.field_border,
|
|
||||||
button_color=theme.field_border,
|
|
||||||
button_hover_color=theme.text_secondary,
|
|
||||||
dropdown_fg_color=theme.field_bg,
|
|
||||||
dropdown_text_color=theme.text_primary,
|
|
||||||
dropdown_hover_color=theme.field_border,
|
|
||||||
corner_radius=8,
|
|
||||||
state="readonly",
|
|
||||||
command=_on_appearance_change,
|
|
||||||
)
|
|
||||||
theme_combo.pack(fill="x")
|
|
||||||
|
|
||||||
conn = _config_section(ctk, frame, theme, t("section.mtproto"))
|
|
||||||
|
|
||||||
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
|
||||||
host_row.pack(fill="x")
|
|
||||||
|
|
||||||
host_col, host_var = _labeled_entry(
|
|
||||||
ctk, host_row, theme, t("label.host"),
|
|
||||||
cfg.get("host", default_config["host"]),
|
|
||||||
tip=t("tip.host"), width=160, pack_fill=True,
|
|
||||||
)
|
|
||||||
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
|
||||||
|
|
||||||
port_col, port_var = _labeled_entry(
|
|
||||||
ctk, host_row, theme, t("label.port"),
|
|
||||||
cfg.get("port", default_config["port"]),
|
|
||||||
tip=t("tip.port"), width=100,
|
|
||||||
)
|
|
||||||
port_col.pack(side="left")
|
|
||||||
|
|
||||||
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
|
|
||||||
secret_row.pack(fill="x")
|
|
||||||
|
|
||||||
secret_col, secret_var = _labeled_entry(
|
|
||||||
ctk, secret_row, theme, t("label.secret"),
|
|
||||||
cfg.get("secret", default_config["secret"]),
|
|
||||||
tip=t("tip.secret"), width=160, pack_fill=True,
|
|
||||||
)
|
|
||||||
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
|
||||||
|
|
||||||
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
|
|
||||||
regen_col.pack(side="left", anchor="s")
|
|
||||||
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
|
||||||
ctk.CTkButton(
|
|
||||||
regen_col, text="↺", width=36, height=36,
|
|
||||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
|
||||||
command=lambda: secret_var.set(os.urandom(16).hex()),
|
|
||||||
).pack()
|
|
||||||
|
|
||||||
dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
|
|
||||||
dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
|
||||||
dc_lbl.pack(anchor="w", pady=(0, 4))
|
|
||||||
dc_textbox = ctk.CTkTextbox(
|
|
||||||
dc_inner, width=_INNER_W, height=88,
|
|
||||||
font=(theme.mono_font_family, 12), corner_radius=10,
|
|
||||||
fg_color=theme.bg, border_color=theme.field_border,
|
|
||||||
border_width=1, text_color=theme.text_primary,
|
|
||||||
)
|
|
||||||
dc_textbox.pack(fill="x")
|
|
||||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
|
||||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
|
||||||
|
|
||||||
cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
|
|
||||||
|
|
||||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
|
||||||
cf_row.pack(fill="x", pady=(0, 4))
|
|
||||||
|
|
||||||
cfproxy_var = ctk.BooleanVar(
|
|
||||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
|
||||||
)
|
|
||||||
cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
|
||||||
cf_cb.pack(side="left", padx=(0, 16))
|
|
||||||
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
|
||||||
|
|
||||||
_cf_test_btn = [None]
|
|
||||||
|
|
||||||
def _on_cf_test():
|
|
||||||
secure = not no_secure_var.get()
|
|
||||||
user_domains = (
|
|
||||||
coerce_domain_list(cfproxy_user_domain_var.get())
|
|
||||||
if cf_custom_cb_var.get() else []
|
|
||||||
)
|
|
||||||
btn = _cf_test_btn[0]
|
|
||||||
if btn:
|
|
||||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
|
||||||
import threading as _threading
|
|
||||||
if user_domains:
|
|
||||||
def _worker():
|
|
||||||
try:
|
|
||||||
per = _run_cfproxy_multi_test(user_domains, secure=secure)
|
|
||||||
if btn:
|
|
||||||
btn.after(
|
|
||||||
0,
|
|
||||||
lambda: _show_multi_connectivity_results(
|
|
||||||
t("connectivity.cfproxy_title"), per, label_prefix='kws',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log.error("CF proxy test failed: %s", exc)
|
|
||||||
finally:
|
|
||||||
if btn:
|
|
||||||
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
|
||||||
else:
|
|
||||||
def _worker_auto():
|
|
||||||
try:
|
|
||||||
ok_domain, res = _run_cfproxy_auto_test(balancer.domains, secure=secure)
|
|
||||||
if btn:
|
|
||||||
btn.after(
|
|
||||||
0,
|
|
||||||
lambda: _show_connectivity_results(
|
|
||||||
t("connectivity.cfproxy_title"), res,
|
|
||||||
domain=ok_domain or '',
|
|
||||||
auto_mode=True,
|
|
||||||
unavailable_message=t("connectivity.cf_auto_fail"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log.error("CF proxy auto-test failed: %s", exc)
|
|
||||||
finally:
|
|
||||||
if btn:
|
|
||||||
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
|
||||||
_threading.Thread(target=_worker_auto, daemon=True).start()
|
|
||||||
|
|
||||||
_cf_test_widget = ctk.CTkButton(
|
|
||||||
cf_row, text=t("button.test"), width=56, height=28,
|
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
|
||||||
command=_on_cf_test,
|
|
||||||
)
|
|
||||||
_cf_test_widget.pack(side="right")
|
|
||||||
_cf_test_btn[0] = _cf_test_widget
|
|
||||||
|
|
||||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
|
||||||
cf_custom_row.pack(fill="x")
|
|
||||||
|
|
||||||
saved_user_domains = coerce_domain_list(
|
|
||||||
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
|
||||||
)
|
|
||||||
cf_custom_cb_var = ctk.BooleanVar(
|
|
||||||
value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
|
|
||||||
)
|
|
||||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
|
||||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
|
||||||
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
|
||||||
|
|
||||||
ctk.CTkButton(
|
|
||||||
cf_custom_row, text="?", width=28, height=32,
|
|
||||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
|
||||||
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
|
|
||||||
).pack(side="right")
|
|
||||||
|
|
||||||
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
|
||||||
cf_domain_entry = _entry(
|
|
||||||
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
|
||||||
height=32, radius=8,
|
|
||||||
)
|
|
||||||
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
|
||||||
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
|
|
||||||
|
|
||||||
def _sync_domain_entry(*_):
|
|
||||||
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
|
||||||
cf_domain_entry.configure(state=state)
|
|
||||||
|
|
||||||
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
|
||||||
_sync_domain_entry()
|
|
||||||
|
|
||||||
cf_worker_inner = _config_section(ctk, frame, theme, t("section.cfworker"))
|
|
||||||
|
|
||||||
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
|
||||||
cf_worker_row.pack(fill="x", pady=(0, 4))
|
|
||||||
cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
|
|
||||||
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
|
|
||||||
|
|
||||||
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
|
||||||
cf_worker_input.pack(fill="x")
|
|
||||||
|
|
||||||
saved_worker_domains = coerce_domain_list(
|
|
||||||
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
|
||||||
)
|
|
||||||
cfproxy_worker_enabled_var = ctk.BooleanVar(
|
|
||||||
value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
|
|
||||||
)
|
|
||||||
cf_worker_cb = _checkbox(
|
|
||||||
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
|
|
||||||
cfproxy_worker_enabled_var,
|
|
||||||
)
|
|
||||||
cf_worker_cb.pack(side="left", padx=(0, 10))
|
|
||||||
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
|
|
||||||
|
|
||||||
cfproxy_worker_domain_var = ctk.StringVar(value=", ".join(saved_worker_domains))
|
|
||||||
cf_worker_entry = _entry(
|
|
||||||
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
|
||||||
height=32, radius=8,
|
|
||||||
)
|
|
||||||
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
|
||||||
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
|
|
||||||
|
|
||||||
_cfworker_test_btn = [None]
|
|
||||||
|
|
||||||
def _sync_cfworker_test_button(*_):
|
|
||||||
btn = _cfworker_test_btn[0]
|
|
||||||
if btn is None:
|
|
||||||
return
|
|
||||||
enabled = (
|
|
||||||
cfproxy_worker_enabled_var.get()
|
|
||||||
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
|
||||||
)
|
|
||||||
btn.configure(state="normal" if enabled else "disabled")
|
|
||||||
|
|
||||||
def _on_cfworker_test():
|
|
||||||
secure = not no_secure_var.get()
|
|
||||||
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
|
||||||
btn = _cfworker_test_btn[0]
|
|
||||||
if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
|
|
||||||
return
|
|
||||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
|
||||||
import threading as _threading
|
|
||||||
|
|
||||||
def _worker():
|
|
||||||
try:
|
|
||||||
per = _run_cfworker_multi_test(domains, secure=secure)
|
|
||||||
btn.after(
|
|
||||||
0,
|
|
||||||
lambda: _show_multi_connectivity_results(
|
|
||||||
t("connectivity.cfworker_title"), per, label_prefix='DC',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log.error("CF worker test failed: %s", exc)
|
|
||||||
finally:
|
|
||||||
btn.after(0, lambda: btn.configure(text=t("button.test")))
|
|
||||||
btn.after(0, _sync_cfworker_test_button)
|
|
||||||
|
|
||||||
_threading.Thread(target=_worker, daemon=True).start()
|
|
||||||
|
|
||||||
ctk.CTkButton(
|
|
||||||
cf_worker_input, text="?", width=28, height=32,
|
|
||||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
|
||||||
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
|
|
||||||
).pack(side="right")
|
|
||||||
|
|
||||||
_cfworker_test_widget = ctk.CTkButton(
|
|
||||||
cf_worker_row, text=t("button.test"), width=56, height=28,
|
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
|
||||||
command=_on_cfworker_test,
|
|
||||||
)
|
|
||||||
_cfworker_test_widget.pack(side="right")
|
|
||||||
_cfworker_test_btn[0] = _cfworker_test_widget
|
|
||||||
|
|
||||||
def _sync_cfworker_entry(*_):
|
|
||||||
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
|
|
||||||
cf_worker_entry.configure(state=state)
|
|
||||||
_sync_cfworker_test_button()
|
|
||||||
|
|
||||||
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
|
|
||||||
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
|
||||||
_sync_cfworker_entry()
|
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
|
|
||||||
|
|
||||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
|
||||||
verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
|
||||||
verbose_cb.pack(anchor="w", pady=(0, 6))
|
|
||||||
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
|
||||||
|
|
||||||
no_secure_var = ctk.BooleanVar(value=cfg.get("no_secure", False))
|
|
||||||
no_secure_cb = _checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
|
|
||||||
no_secure_cb.pack(anchor="w", pady=(0, 6))
|
|
||||||
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
|
|
||||||
|
|
||||||
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
|
||||||
adv_frame.pack(fill="x")
|
|
||||||
|
|
||||||
adv_rows = [
|
|
||||||
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
|
||||||
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
|
||||||
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
|
||||||
]
|
|
||||||
for label_text, key, tip in adv_rows:
|
|
||||||
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
|
||||||
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
|
|
||||||
adv_l = _label(ctk, col, theme, label_text, size=11)
|
|
||||||
adv_l.pack(anchor="w", pady=(0, 2))
|
|
||||||
adv_e = _entry(
|
|
||||||
ctk, col, theme, width=_INNER_W, height=32, radius=8,
|
|
||||||
textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
|
|
||||||
)
|
|
||||||
adv_e.pack(fill="x")
|
|
||||||
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
|
|
||||||
|
|
||||||
adv_entries = list(adv_frame.winfo_children())
|
|
||||||
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
|
|
||||||
|
|
||||||
upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
|
|
||||||
st = get_status()
|
|
||||||
check_updates_var = ctk.BooleanVar(
|
|
||||||
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
|
||||||
)
|
|
||||||
upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
|
||||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
|
||||||
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
|
||||||
|
|
||||||
if st.get("error"):
|
|
||||||
upd_status = t("updates.status_error")
|
|
||||||
elif not st.get("checked"):
|
|
||||||
upd_status = t("updates.status_pending")
|
|
||||||
elif st.get("has_update") and st.get("latest"):
|
|
||||||
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
|
||||||
elif st.get("ahead_of_release") and st.get("latest"):
|
|
||||||
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
|
||||||
else:
|
|
||||||
upd_status = t("updates.status_latest")
|
|
||||||
|
|
||||||
_label(ctk, upd_inner, theme, upd_status, size=11,
|
|
||||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
|
||||||
|
|
||||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
|
||||||
if st.get("has_update") and on_update_click is not None:
|
|
||||||
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
|
|
||||||
upd_btn_row.pack(fill="x")
|
|
||||||
upd_btn_row.grid_columnconfigure(0, weight=1)
|
|
||||||
upd_btn_row.grid_columnconfigure(1, weight=1)
|
|
||||||
ctk.CTkButton(
|
|
||||||
upd_btn_row, text=t("button.open_release"), height=32,
|
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
|
||||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
|
||||||
text_color=theme.text_primary, border_width=1,
|
|
||||||
border_color=theme.field_border,
|
|
||||||
command=lambda u=rel_url: webbrowser.open(u),
|
|
||||||
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
|
|
||||||
ctk.CTkButton(
|
|
||||||
upd_btn_row, text=t("button.update"), height=32,
|
|
||||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
|
||||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
|
||||||
text_color="#ffffff",
|
|
||||||
command=on_update_click,
|
|
||||||
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
|
|
||||||
else:
|
|
||||||
ctk.CTkButton(
|
|
||||||
upd_inner, text=t("button.open_release"), height=32,
|
|
||||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
|
||||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
|
||||||
text_color=theme.text_primary, border_width=1,
|
|
||||||
border_color=theme.field_border,
|
|
||||||
command=lambda u=rel_url: webbrowser.open(u),
|
|
||||||
).pack(anchor="w")
|
|
||||||
|
|
||||||
autostart_var = None
|
|
||||||
if show_autostart:
|
|
||||||
sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
|
||||||
autostart_var = ctk.BooleanVar(value=autostart_value)
|
|
||||||
as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
|
||||||
as_cb.pack(anchor="w", pady=(0, 4))
|
|
||||||
as_hint = _label(
|
|
||||||
ctk, sys_inner, theme,
|
|
||||||
t("label.autostart_hint"),
|
|
||||||
size=11, justify="left", wraplength=_INNER_W,
|
|
||||||
)
|
|
||||||
as_hint.pack(anchor="w")
|
|
||||||
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
|
||||||
|
|
||||||
return TrayConfigFormWidgets(
|
|
||||||
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
|
||||||
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
|
|
||||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
|
||||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
|
||||||
cfproxy_var=cfproxy_var,
|
|
||||||
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
|
|
||||||
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
|
||||||
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
|
|
||||||
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
|
||||||
appearance_var=appearance_var,
|
|
||||||
language_var=language_var,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_adv_from_form(
|
|
||||||
widgets: TrayConfigFormWidgets,
|
|
||||||
base: Dict[str, Any],
|
|
||||||
default_config: dict,
|
|
||||||
) -> None:
|
|
||||||
for i, key in enumerate(widgets.adv_keys):
|
|
||||||
col_frame = widgets.adv_entries[i]
|
|
||||||
entry = col_frame.winfo_children()[1]
|
|
||||||
try:
|
|
||||||
val = float(entry.get().strip())
|
|
||||||
if key in ("buf_kb", "pool_size"):
|
|
||||||
val = int(val)
|
|
||||||
base[key] = val
|
|
||||||
except ValueError:
|
|
||||||
base[key] = default_config[key]
|
|
||||||
|
|
||||||
|
|
||||||
def _dc_validation_message(error: ValueError) -> str:
|
|
||||||
exc_entry = getattr(error, "entry", None)
|
|
||||||
if exc_entry is None:
|
|
||||||
return str(error)
|
|
||||||
kind = getattr(error, "kind", "invalid")
|
|
||||||
if kind == "format":
|
|
||||||
return t("validation.dc_format", entry=exc_entry)
|
|
||||||
return t("validation.dc_invalid", entry=exc_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_config_form(
|
|
||||||
widgets: TrayConfigFormWidgets,
|
|
||||||
default_config: dict,
|
|
||||||
*,
|
|
||||||
include_autostart: bool,
|
|
||||||
) -> Union[dict, str]:
|
|
||||||
import socket as _sock
|
|
||||||
|
|
||||||
host_val = widgets.host_var.get().strip()
|
|
||||||
try:
|
|
||||||
_sock.inet_aton(host_val)
|
|
||||||
except OSError:
|
|
||||||
return t("validation.bad_host")
|
|
||||||
|
|
||||||
try:
|
|
||||||
port_val = int(widgets.port_var.get().strip())
|
|
||||||
if not (1 <= port_val <= 65535):
|
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
|
||||||
return t("validation.bad_port")
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
line.strip()
|
|
||||||
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
|
||||||
if line.strip()
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
parse_dc_ip_list(lines)
|
|
||||||
except ValueError as e:
|
|
||||||
return _dc_validation_message(e)
|
|
||||||
|
|
||||||
secret_val = widgets.secret_var.get().strip()
|
|
||||||
if len(secret_val) != 32:
|
|
||||||
return t("validation.bad_secret_len")
|
|
||||||
try:
|
|
||||||
bytes.fromhex(secret_val)
|
|
||||||
except ValueError:
|
|
||||||
return t("validation.bad_secret_hex")
|
|
||||||
|
|
||||||
new_cfg: Dict[str, Any] = {
|
|
||||||
"host": host_val,
|
|
||||||
"port": port_val,
|
|
||||||
"secret": secret_val,
|
|
||||||
"dc_ip": lines,
|
|
||||||
"verbose": widgets.verbose_var.get(),
|
|
||||||
}
|
|
||||||
if include_autostart:
|
|
||||||
new_cfg["autostart"] = (
|
|
||||||
widgets.autostart_var.get()
|
|
||||||
if widgets.autostart_var is not None
|
|
||||||
else False
|
|
||||||
)
|
|
||||||
|
|
||||||
merge_adv_from_form(widgets, new_cfg, default_config)
|
|
||||||
if widgets.check_updates_var is not None:
|
|
||||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
|
||||||
if widgets.cfproxy_var is not None:
|
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
|
||||||
if widgets.cfproxy_user_domain_enabled_var is not None:
|
|
||||||
new_cfg["cfproxy_user_domain_enabled"] = bool(
|
|
||||||
widgets.cfproxy_user_domain_enabled_var.get()
|
|
||||||
)
|
|
||||||
if widgets.cfproxy_user_domain_var is not None:
|
|
||||||
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
|
||||||
if widgets.cfproxy_worker_enabled_var is not None:
|
|
||||||
new_cfg["cfproxy_worker_enabled"] = bool(widgets.cfproxy_worker_enabled_var.get())
|
|
||||||
if widgets.cfproxy_worker_domain_var is not None:
|
|
||||||
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
|
|
||||||
if widgets.appearance_var is not None:
|
|
||||||
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
|
|
||||||
if widgets.language_var is not None:
|
|
||||||
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
|
|
||||||
if widgets.no_secure_var is not None:
|
|
||||||
new_cfg["no_secure"] = bool(widgets.no_secure_var.get())
|
|
||||||
return new_cfg
|
|
||||||
|
|
||||||
|
|
||||||
def install_tray_config_buttons(
|
def install_tray_config_buttons(
|
||||||
ctk: Any,
|
ctk: Any,
|
||||||
frame: Any,
|
frame: Any,
|
||||||
@@ -1052,7 +137,7 @@ def populate_first_run_window(
|
|||||||
corner_radius=0).pack(fill="x", pady=(0, 12))
|
corner_radius=0).pack(fill="x", pady=(0, 12))
|
||||||
|
|
||||||
auto_var = ctk.BooleanVar(value=True)
|
auto_var = ctk.BooleanVar(value=True)
|
||||||
_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
create_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
||||||
auto_var).pack(anchor="w", pady=(0, 16))
|
auto_var).pack(anchor="w", pady=(0, 16))
|
||||||
|
|
||||||
def on_ok():
|
def on_ok():
|
||||||
|
|||||||
+10
-7
@@ -19,6 +19,8 @@
|
|||||||
"section.mtproto": "MTProto Connection",
|
"section.mtproto": "MTProto Connection",
|
||||||
"section.dc": "Telegram Data Centers (DC → IP)",
|
"section.dc": "Telegram Data Centers (DC → IP)",
|
||||||
"section.cfproxy": "Cloudflare Proxy",
|
"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.cfworker": "Cloudflare Worker",
|
||||||
"section.logs": "Logs & Performance",
|
"section.logs": "Logs & Performance",
|
||||||
"section.updates": "Updates",
|
"section.updates": "Updates",
|
||||||
@@ -47,12 +49,12 @@
|
|||||||
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
|
"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.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.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.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.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.check_updates": "Check for updates on startup",
|
||||||
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
|
"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.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.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
|
||||||
"tip.save": "Save settings",
|
"tip.save": "Save settings",
|
||||||
@@ -65,8 +67,10 @@
|
|||||||
"button.open_release": "Open release page",
|
"button.open_release": "Open release page",
|
||||||
"button.start": "Get started",
|
"button.start": "Get started",
|
||||||
"button.update": "Update",
|
"button.update": "Update",
|
||||||
|
"button.check_updates": "Check for updates",
|
||||||
"button.page": "Page",
|
"button.page": "Page",
|
||||||
"button.close": "Close",
|
"button.close": "Close",
|
||||||
|
"button.donate": "Donate ♥",
|
||||||
|
|
||||||
"validation.bad_host": "Invalid IP address.",
|
"validation.bad_host": "Invalid IP address.",
|
||||||
"validation.bad_port": "Port must be a number between 1 and 65535",
|
"validation.bad_port": "Port must be a number between 1 and 65535",
|
||||||
@@ -94,7 +98,8 @@
|
|||||||
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
|
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
|
||||||
|
|
||||||
"updates.status_error": "Could not reach GitHub. Check your network.",
|
"updates.status_error": "Could not reach GitHub. Check your network.",
|
||||||
"updates.status_pending": "Status will appear after the background check on startup.",
|
"updates.status_pending": "Updates have not been checked yet.",
|
||||||
|
"updates.status_checking": "Checking for updates...",
|
||||||
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
|
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
|
||||||
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
|
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
|
||||||
"updates.status_latest": "Latest known version from GitHub is installed.",
|
"updates.status_latest": "Latest known version from GitHub is installed.",
|
||||||
@@ -134,13 +139,11 @@
|
|||||||
|
|
||||||
"update.available": "New version available: {version}",
|
"update.available": "New version available: {version}",
|
||||||
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
|
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
|
||||||
|
"update.ask_install": "New version available: {version}\n\nUpdate now? The app will restart.",
|
||||||
|
"update.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?",
|
||||||
"update.downloading": "Downloading...",
|
"update.downloading": "Downloading...",
|
||||||
"update.replacing": "Replacing file...",
|
|
||||||
"update.restarting": "Restarting...",
|
"update.restarting": "Restarting...",
|
||||||
"update.error": "Error: {msg}",
|
"update.error": "Error: {msg}",
|
||||||
"update.download_fail": "Download failed:\n{error}",
|
|
||||||
"update.rename_fail": "Failed to rename file:\n{error}",
|
|
||||||
"update.move_fail": "Failed to move file:\n{error}",
|
|
||||||
|
|
||||||
"error.dc_config": "DC → IP configuration error.",
|
"error.dc_config": "DC → IP configuration error.",
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -19,6 +19,8 @@
|
|||||||
"section.mtproto": "Подключение MTProto",
|
"section.mtproto": "Подключение MTProto",
|
||||||
"section.dc": "Датацентры Telegram (DC → IP)",
|
"section.dc": "Датацентры Telegram (DC → IP)",
|
||||||
"section.cfproxy": "Cloudflare Proxy",
|
"section.cfproxy": "Cloudflare Proxy",
|
||||||
|
"label.h2_enable": "Мультиплексирование медиа (HTTP/2)",
|
||||||
|
"tip.h2": "Объединять загрузку медиа через Cloudflare в общее HTTP/2-соединение для каждого датацентра и домена.\nТребуется включённый CF-прокси и включённый TLS.",
|
||||||
"section.cfworker": "Cloudflare Worker",
|
"section.cfworker": "Cloudflare Worker",
|
||||||
"section.logs": "Логи и производительность",
|
"section.logs": "Логи и производительность",
|
||||||
"section.updates": "Обновления",
|
"section.updates": "Обновления",
|
||||||
@@ -26,13 +28,13 @@
|
|||||||
|
|
||||||
"label.host": "IP-адрес",
|
"label.host": "IP-адрес",
|
||||||
"label.port": "Порт",
|
"label.port": "Порт",
|
||||||
"label.secret": "Secret",
|
"label.secret": "Ключ",
|
||||||
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
|
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
|
||||||
"label.cf_enable": "Включить CF-прокси",
|
"label.cf_enable": "Включить CF-прокси",
|
||||||
"label.cf_custom_domain": "Свой домен",
|
"label.cf_custom_domain": "Свой домен",
|
||||||
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
|
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
|
||||||
"label.verbose": "Подробное логирование (verbose)",
|
"label.verbose": "Подробное логирование (verbose)",
|
||||||
"label.no_secure": "Выключиь TLS для CF-прокси и CF-worker",
|
"label.no_secure": "Выключить TLS для CF-прокси и CF-worker",
|
||||||
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
|
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
|
||||||
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
|
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
|
||||||
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
|
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
|
||||||
@@ -47,12 +49,12 @@
|
|||||||
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
|
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
|
||||||
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
|
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
|
||||||
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
|
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
|
||||||
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
|
"tip.pool": "Сколько готовых WebSocket-соединений держать для каждого датацентра.\n0 отключает прямой DC->IP WebSocket-маршрут",
|
||||||
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
|
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
|
||||||
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
|
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
|
||||||
"tip.check_updates": "При запуске проверять наличие обновлений",
|
"tip.check_updates": "При запуске проверять наличие обновлений",
|
||||||
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
|
"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.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
|
||||||
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
|
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
|
||||||
"tip.save": "Сохранить настройки",
|
"tip.save": "Сохранить настройки",
|
||||||
@@ -65,13 +67,15 @@
|
|||||||
"button.open_release": "Открыть страницу релиза",
|
"button.open_release": "Открыть страницу релиза",
|
||||||
"button.start": "Начать",
|
"button.start": "Начать",
|
||||||
"button.update": "Обновить",
|
"button.update": "Обновить",
|
||||||
|
"button.check_updates": "Проверить обновления",
|
||||||
"button.page": "Страница",
|
"button.page": "Страница",
|
||||||
"button.close": "Закрыть",
|
"button.close": "Закрыть",
|
||||||
|
"button.donate": "Поддержать ♥",
|
||||||
|
|
||||||
"validation.bad_host": "Некорректный IP-адрес.",
|
"validation.bad_host": "Некорректный IP-адрес.",
|
||||||
"validation.bad_port": "Порт должен быть числом 1-65535",
|
"validation.bad_port": "Порт должен быть числом 1-65535",
|
||||||
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
|
"validation.bad_secret_len": "Ключ должен содержать ровно 32 hex-символа (16 байт).",
|
||||||
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
|
"validation.bad_secret_hex": "Ключ должен состоять только из hex-символов (0-9, a-f).",
|
||||||
"validation.dc_format": "Неверный формат DC:IP: {entry}",
|
"validation.dc_format": "Неверный формат DC:IP: {entry}",
|
||||||
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
|
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
|
||||||
|
|
||||||
@@ -94,7 +98,8 @@
|
|||||||
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
|
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
|
||||||
|
|
||||||
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
|
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
|
||||||
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
|
"updates.status_pending": "Обновления ещё не проверялись.",
|
||||||
|
"updates.status_checking": "Проверка обновлений...",
|
||||||
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
|
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
|
||||||
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
|
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
|
||||||
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
|
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
|
||||||
@@ -107,7 +112,7 @@
|
|||||||
"first_run.manual": " Вручную:",
|
"first_run.manual": " Вручную:",
|
||||||
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
|
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
|
||||||
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||||
"first_run.manual_secret": " Secret: dd{secret}",
|
"first_run.manual_secret": " Ключ: dd{secret}",
|
||||||
"first_run.open_now": "Открыть прокси в Telegram сейчас",
|
"first_run.open_now": "Открыть прокси в Telegram сейчас",
|
||||||
|
|
||||||
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
|
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
|
||||||
@@ -134,13 +139,11 @@
|
|||||||
|
|
||||||
"update.available": "Доступна новая версия: {version}",
|
"update.available": "Доступна новая версия: {version}",
|
||||||
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
|
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
|
||||||
|
"update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.",
|
||||||
|
"update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?",
|
||||||
"update.downloading": "Скачивание...",
|
"update.downloading": "Скачивание...",
|
||||||
"update.replacing": "Замена файла...",
|
|
||||||
"update.restarting": "Перезапуск...",
|
"update.restarting": "Перезапуск...",
|
||||||
"update.error": "Ошибка: {msg}",
|
"update.error": "Ошибка: {msg}",
|
||||||
"update.download_fail": "Не удалось скачать:\n{error}",
|
|
||||||
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
|
|
||||||
"update.move_fail": "Не удалось переместить файл:\n{error}",
|
|
||||||
|
|
||||||
"error.dc_config": "Ошибка конфигурации DC → IP.",
|
"error.dc_config": "Ошибка конфигурации DC → IP.",
|
||||||
|
|
||||||
|
|||||||
@@ -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,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
from ui.settings_form import validate_config_form
|
||||||
|
from ui.i18n import set_language, t
|
||||||
|
from ui.settings import prepare_settings
|
||||||
|
|
||||||
|
log = logging.getLogger("tg-ws-tray")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog:
|
||||||
|
def __init__(
|
||||||
|
self, *, ctk, root, widgets, config, defaults, persist,
|
||||||
|
refresh_menu, finish, restart, include_autostart=False,
|
||||||
|
apply_autostart=None, read_autostart=None,
|
||||||
|
):
|
||||||
|
self.ctk = ctk
|
||||||
|
self.root = root
|
||||||
|
self.widgets = widgets
|
||||||
|
self.config = config
|
||||||
|
self.defaults = defaults
|
||||||
|
self.persist = persist
|
||||||
|
self.refresh_menu = refresh_menu
|
||||||
|
self.finish = finish
|
||||||
|
self.restart = restart
|
||||||
|
self.include_autostart = include_autostart
|
||||||
|
self.apply_autostart = apply_autostart
|
||||||
|
self.read_autostart = read_autostart
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
if self.closed:
|
||||||
|
return
|
||||||
|
mode = self.config.get("appearance", "auto")
|
||||||
|
self.ctk.set_appearance_mode("system" if mode == "auto" else mode)
|
||||||
|
set_language(self.config.get("language", self.defaults["language"]))
|
||||||
|
self.refresh_menu()
|
||||||
|
self._finish()
|
||||||
|
|
||||||
|
def _finish(self):
|
||||||
|
self.closed = True
|
||||||
|
self.finish()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
if self.closed:
|
||||||
|
return
|
||||||
|
values = validate_config_form(
|
||||||
|
self.widgets, self.defaults, include_autostart=self.include_autostart,
|
||||||
|
)
|
||||||
|
if isinstance(values, str):
|
||||||
|
messagebox.showerror(t("app.error_title"), values, parent=self.root)
|
||||||
|
return
|
||||||
|
change = prepare_settings(self.config, values, self.defaults)
|
||||||
|
autostart_changed = (
|
||||||
|
self.include_autostart
|
||||||
|
and self.read_autostart is not None
|
||||||
|
and bool(values["autostart"]) != self.read_autostart()
|
||||||
|
)
|
||||||
|
if not change.changed_keys and not autostart_changed:
|
||||||
|
self._finish()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.persist(change.config)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
log.exception("Failed to save settings")
|
||||||
|
messagebox.showerror(t("app.error_title"), str(exc), parent=self.root)
|
||||||
|
return
|
||||||
|
self.config.update(deepcopy(change.config))
|
||||||
|
set_language(change.config["language"])
|
||||||
|
changed_keys = change.changed_keys | ({"autostart"} if autostart_changed else set())
|
||||||
|
log.info("Settings saved: %s", ", ".join(sorted(changed_keys)))
|
||||||
|
if self.apply_autostart is not None:
|
||||||
|
self.apply_autostart(bool(change.config.get("autostart", False)))
|
||||||
|
self.refresh_menu()
|
||||||
|
do_restart = change.requires_restart and messagebox.askyesno(
|
||||||
|
t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root,
|
||||||
|
)
|
||||||
|
self._finish()
|
||||||
|
if do_restart:
|
||||||
|
threading.Thread(
|
||||||
|
target=self.restart, args=(deepcopy(change.config),),
|
||||||
|
daemon=True, name="proxy-restart",
|
||||||
|
).start()
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import webbrowser
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from proxy import __version__, coerce_domain_list
|
||||||
|
from proxy.balancer import balancer
|
||||||
|
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||||
|
from ui.background_task import BackgroundTask
|
||||||
|
from ui.connectivity import (
|
||||||
|
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
|
||||||
|
show_connectivity_results, show_multi_connectivity_results,
|
||||||
|
)
|
||||||
|
from ui.ctk_controls import (
|
||||||
|
create_entry, create_checkbox, create_label, create_labeled_entry,
|
||||||
|
create_config_section, create_combobox,
|
||||||
|
)
|
||||||
|
from ui.ctk_theme import CtkTheme
|
||||||
|
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||||
|
from ui.settings import validate_settings
|
||||||
|
from ui.i18n import (
|
||||||
|
label_from_language, language_from_label, language_option_labels,
|
||||||
|
set_language, get_language, t,
|
||||||
|
)
|
||||||
|
|
||||||
|
_INNER_W = 396
|
||||||
|
_APPEARANCE_KEYS = ("auto", "light", "dark")
|
||||||
|
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_doc_url(doc_name: str) -> str:
|
||||||
|
lang = get_language().value
|
||||||
|
lang_folder = "EN" if lang == "en" else "RU"
|
||||||
|
return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_options() -> List[str]:
|
||||||
|
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_from_cfg(value: str) -> str:
|
||||||
|
if value in _APPEARANCE_KEYS:
|
||||||
|
return t(f"appearance.{value}")
|
||||||
|
return t("appearance.auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_to_cfg(label: str) -> str:
|
||||||
|
for key in _APPEARANCE_KEYS:
|
||||||
|
if t(f"appearance.{key}") == label:
|
||||||
|
return key
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrayConfigFormWidgets:
|
||||||
|
host_var: Any
|
||||||
|
port_var: Any
|
||||||
|
secret_var: Any
|
||||||
|
dc_textbox: Any
|
||||||
|
verbose_var: Any
|
||||||
|
no_secure_var: Any
|
||||||
|
advanced_vars: Dict[str, Any]
|
||||||
|
autostart_var: Optional[Any]
|
||||||
|
check_updates_var: Optional[Any]
|
||||||
|
cfproxy_var: Optional[Any] = None
|
||||||
|
h2_var: Optional[Any] = None
|
||||||
|
cfproxy_user_domain_enabled_var: Optional[Any] = None
|
||||||
|
cfproxy_user_domain_var: Optional[Any] = None
|
||||||
|
cfproxy_worker_enabled_var: Optional[Any] = None
|
||||||
|
cfproxy_worker_domain_var: Optional[Any] = None
|
||||||
|
appearance_var: Optional[Any] = None
|
||||||
|
language_var: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
def install_tray_config_form(
|
||||||
|
ctk: Any,
|
||||||
|
frame: Any,
|
||||||
|
theme: CtkTheme,
|
||||||
|
cfg: dict,
|
||||||
|
default_config: dict,
|
||||||
|
*,
|
||||||
|
show_autostart: bool = False,
|
||||||
|
autostart_value: bool = False,
|
||||||
|
on_update_click: Optional[Callable[[], None]] = None,
|
||||||
|
) -> TrayConfigFormWidgets:
|
||||||
|
set_language(cfg.get("language", default_config["language"]))
|
||||||
|
no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False))
|
||||||
|
|
||||||
|
_create_header(ctk, frame, theme)
|
||||||
|
appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config)
|
||||||
|
host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config)
|
||||||
|
dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config)
|
||||||
|
cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy(
|
||||||
|
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||||
|
)
|
||||||
|
cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker(
|
||||||
|
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||||
|
)
|
||||||
|
verbose_var, advanced_vars = _create_logging(
|
||||||
|
ctk, frame, theme, cfg, default_config, no_secure_var,
|
||||||
|
)
|
||||||
|
check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click)
|
||||||
|
autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value)
|
||||||
|
|
||||||
|
return TrayConfigFormWidgets(
|
||||||
|
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
||||||
|
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
|
||||||
|
advanced_vars=advanced_vars,
|
||||||
|
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||||
|
cfproxy_var=cfproxy_var,
|
||||||
|
h2_var=h2_var,
|
||||||
|
cfproxy_user_domain_enabled_var=cf_custom_cb_var,
|
||||||
|
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||||
|
cfproxy_worker_enabled_var=cfproxy_worker_enabled_var,
|
||||||
|
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||||
|
appearance_var=appearance_var,
|
||||||
|
language_var=language_var,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_header(ctk, frame, theme):
|
||||||
|
header = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
|
header.pack(fill="x", pady=(0, 2))
|
||||||
|
ctk.CTkLabel(
|
||||||
|
header, text=t("settings.title"),
|
||||||
|
font=(theme.ui_font_family, 17, "bold"),
|
||||||
|
text_color=theme.text_primary, anchor="w",
|
||||||
|
).pack(side="left")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
header, text=f"v{__version__}",
|
||||||
|
font=(theme.ui_font_family, 12),
|
||||||
|
text_color=theme.text_secondary, anchor="e",
|
||||||
|
).pack(side="right", padx=(4, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
header, text=t("button.donate"), width=90, height=28,
|
||||||
|
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||||
|
fg_color="#22c55e", hover_color="#16a34a",
|
||||||
|
text_color="#ffffff", border_width=0,
|
||||||
|
command=lambda: (
|
||||||
|
header.winfo_toplevel().iconify(),
|
||||||
|
webbrowser.open(_get_doc_url("Funding")),
|
||||||
|
),
|
||||||
|
).pack(side="right", padx=(0, 6))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_interface(ctk, frame, theme, cfg, default_config):
|
||||||
|
lang_cfg = cfg.get("language", default_config["language"])
|
||||||
|
appearance_var = ctk.StringVar(
|
||||||
|
master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_appearance_change(choice: str) -> None:
|
||||||
|
cfg_val = _appearance_to_cfg(choice)
|
||||||
|
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||||
|
|
||||||
|
ui_inner = create_config_section(ctk, frame, theme, t("section.interface"))
|
||||||
|
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
|
||||||
|
ui_row.pack(fill="x")
|
||||||
|
|
||||||
|
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||||
|
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||||
|
|
||||||
|
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
|
||||||
|
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||||
|
|
||||||
|
language_var = ctk.StringVar(master=frame, value=label_from_language(lang_cfg))
|
||||||
|
create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
|
||||||
|
anchor="w", pady=(0, 2)
|
||||||
|
)
|
||||||
|
create_combobox(
|
||||||
|
ctk, lang_col, theme, variable=language_var,
|
||||||
|
values=[label for _, label in language_option_labels()],
|
||||||
|
).pack(fill="x")
|
||||||
|
create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
|
||||||
|
anchor="w", pady=(0, 2)
|
||||||
|
)
|
||||||
|
create_combobox(
|
||||||
|
ctk, theme_col, theme, variable=appearance_var,
|
||||||
|
values=_appearance_options(), command=_on_appearance_change,
|
||||||
|
).pack(fill="x")
|
||||||
|
return appearance_var, language_var
|
||||||
|
|
||||||
|
|
||||||
|
def _create_connection(ctk, frame, theme, cfg, default_config):
|
||||||
|
conn = create_config_section(ctk, frame, theme, t("section.mtproto"))
|
||||||
|
|
||||||
|
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||||
|
host_row.pack(fill="x")
|
||||||
|
|
||||||
|
host_col, host_var = create_labeled_entry(
|
||||||
|
ctk, host_row, theme, t("label.host"),
|
||||||
|
cfg.get("host", default_config["host"]),
|
||||||
|
tip=t("tip.host"), width=160, pack_fill=True,
|
||||||
|
)
|
||||||
|
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
|
||||||
|
port_col, port_var = create_labeled_entry(
|
||||||
|
ctk, host_row, theme, t("label.port"),
|
||||||
|
cfg.get("port", default_config["port"]),
|
||||||
|
tip=t("tip.port"), width=100,
|
||||||
|
)
|
||||||
|
port_col.pack(side="left")
|
||||||
|
|
||||||
|
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||||
|
secret_row.pack(fill="x")
|
||||||
|
|
||||||
|
secret_col, secret_var = create_labeled_entry(
|
||||||
|
ctk, secret_row, theme, t("label.secret"),
|
||||||
|
cfg.get("secret", default_config["secret"]),
|
||||||
|
tip=t("tip.secret"), width=160, pack_fill=True,
|
||||||
|
)
|
||||||
|
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
|
||||||
|
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
|
||||||
|
regen_col.pack(side="left", anchor="s")
|
||||||
|
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
||||||
|
ctk.CTkButton(
|
||||||
|
regen_col, text="↺", width=36, height=36,
|
||||||
|
font=(theme.ui_font_family, 18), corner_radius=10,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=lambda: secret_var.set(os.urandom(16).hex()),
|
||||||
|
).pack()
|
||||||
|
return host_var, port_var, secret_var
|
||||||
|
|
||||||
|
|
||||||
|
def _create_dc(ctk, frame, theme, cfg, default_config):
|
||||||
|
dc_inner = create_config_section(ctk, frame, theme, t("section.dc"))
|
||||||
|
dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
||||||
|
dc_lbl.pack(anchor="w", pady=(0, 4))
|
||||||
|
dc_textbox = ctk.CTkTextbox(
|
||||||
|
dc_inner, width=_INNER_W, height=88,
|
||||||
|
font=(theme.mono_font_family, 12), corner_radius=10,
|
||||||
|
fg_color=theme.bg, border_color=theme.field_border,
|
||||||
|
border_width=1, text_color=theme.text_primary,
|
||||||
|
)
|
||||||
|
dc_textbox.pack(fill="x")
|
||||||
|
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||||
|
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
||||||
|
return dc_textbox
|
||||||
|
|
||||||
|
|
||||||
|
def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||||
|
cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy"))
|
||||||
|
|
||||||
|
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
|
cf_row.pack(fill="x", pady=(0, 4))
|
||||||
|
|
||||||
|
cfproxy_var = ctk.BooleanVar(
|
||||||
|
master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||||
|
)
|
||||||
|
cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
||||||
|
cf_cb.pack(side="left", padx=(0, 16))
|
||||||
|
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
||||||
|
|
||||||
|
cf_test = BackgroundTask(frame.winfo_toplevel())
|
||||||
|
|
||||||
|
def _on_cf_test():
|
||||||
|
secure = not no_secure_var.get()
|
||||||
|
user_domains = (
|
||||||
|
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||||
|
if cf_custom_cb_var.get() else []
|
||||||
|
)
|
||||||
|
if cf_test.running:
|
||||||
|
return
|
||||||
|
_cf_test_widget.configure(text=t("button.test_loading"), state="disabled")
|
||||||
|
|
||||||
|
def finish():
|
||||||
|
_cf_test_widget.configure(text=t("button.test"), state="normal")
|
||||||
|
|
||||||
|
if user_domains:
|
||||||
|
cf_test.start(
|
||||||
|
lambda: run_cfproxy_multi_test(user_domains, secure=secure),
|
||||||
|
lambda per: show_multi_connectivity_results(
|
||||||
|
t("connectivity.cfproxy_title"), per, label_prefix="kws",
|
||||||
|
parent=frame.winfo_toplevel(),
|
||||||
|
),
|
||||||
|
finish,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
domains = list(balancer.domains)
|
||||||
|
cf_test.start(
|
||||||
|
lambda: run_cfproxy_auto_test(domains, secure=secure),
|
||||||
|
lambda result: show_connectivity_results(
|
||||||
|
t("connectivity.cfproxy_title"), result[1],
|
||||||
|
domain=result[0] or "", auto_mode=True,
|
||||||
|
unavailable_message=t("connectivity.cf_auto_fail"),
|
||||||
|
parent=frame.winfo_toplevel(),
|
||||||
|
),
|
||||||
|
finish,
|
||||||
|
)
|
||||||
|
|
||||||
|
_cf_test_widget = ctk.CTkButton(
|
||||||
|
cf_row, text=t("button.test"), width=56, height=28,
|
||||||
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=_on_cf_test,
|
||||||
|
)
|
||||||
|
_cf_test_widget.pack(side="right")
|
||||||
|
|
||||||
|
h2_var = ctk.BooleanVar(master=frame, value=cfg.get("h2", default_config.get("h2", True)))
|
||||||
|
h2_cb = create_checkbox(ctk, cf_inner, theme, t("label.h2_enable"), h2_var)
|
||||||
|
h2_cb.pack(anchor="w", pady=(2, 8))
|
||||||
|
attach_ctk_tooltip(h2_cb, t("tip.h2"))
|
||||||
|
|
||||||
|
def _sync_h2(*_):
|
||||||
|
enabled = cfproxy_var.get() and not no_secure_var.get() and not cfg.get("force_test_dc", False)
|
||||||
|
h2_cb.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
|
cfproxy_var.trace_add("write", _sync_h2)
|
||||||
|
no_secure_var.trace_add("write", _sync_h2)
|
||||||
|
_sync_h2()
|
||||||
|
|
||||||
|
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
|
cf_custom_row.pack(fill="x")
|
||||||
|
|
||||||
|
saved_user_domains = coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
|
)
|
||||||
|
cf_custom_cb_var = ctk.BooleanVar(
|
||||||
|
master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains))
|
||||||
|
)
|
||||||
|
cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
||||||
|
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||||
|
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
cf_custom_row, text="?", width=28, height=32,
|
||||||
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=lambda: webbrowser.open(_get_doc_url("CfProxy")),
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains))
|
||||||
|
cf_domain_entry = create_entry(
|
||||||
|
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||||
|
height=32, radius=8,
|
||||||
|
)
|
||||||
|
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
|
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
|
||||||
|
|
||||||
|
def _sync_domain_entry(*_):
|
||||||
|
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||||
|
cf_domain_entry.configure(state=state)
|
||||||
|
|
||||||
|
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||||
|
_sync_domain_entry()
|
||||||
|
return cfproxy_var, h2_var, cf_custom_cb_var, cfproxy_user_domain_var
|
||||||
|
|
||||||
|
|
||||||
|
def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||||
|
cf_worker_inner = create_config_section(ctk, frame, theme, t("section.cfworker"))
|
||||||
|
|
||||||
|
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
|
cf_worker_row.pack(fill="x", pady=(0, 4))
|
||||||
|
cf_worker_lbl = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
|
||||||
|
cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2))
|
||||||
|
|
||||||
|
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
|
cf_worker_input.pack(fill="x")
|
||||||
|
|
||||||
|
saved_worker_domains = coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||||
|
)
|
||||||
|
cfproxy_worker_enabled_var = ctk.BooleanVar(
|
||||||
|
master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains))
|
||||||
|
)
|
||||||
|
cf_worker_cb = create_checkbox(
|
||||||
|
ctk, cf_worker_input, theme, t("label.cf_custom_domain"),
|
||||||
|
cfproxy_worker_enabled_var,
|
||||||
|
)
|
||||||
|
cf_worker_cb.pack(side="left", padx=(0, 10))
|
||||||
|
attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain"))
|
||||||
|
|
||||||
|
cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains))
|
||||||
|
cf_worker_entry = create_entry(
|
||||||
|
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||||
|
height=32, radius=8,
|
||||||
|
)
|
||||||
|
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||||
|
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
|
||||||
|
|
||||||
|
worker_test = BackgroundTask(frame.winfo_toplevel())
|
||||||
|
|
||||||
|
def _sync_cfworker_test_button(*_):
|
||||||
|
btn = _cfworker_test_widget
|
||||||
|
enabled = (
|
||||||
|
not worker_test.running and cfproxy_worker_enabled_var.get()
|
||||||
|
and bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||||
|
)
|
||||||
|
btn.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
|
def _on_cfworker_test():
|
||||||
|
secure = not no_secure_var.get()
|
||||||
|
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||||
|
if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains:
|
||||||
|
return
|
||||||
|
_cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled")
|
||||||
|
|
||||||
|
def finish():
|
||||||
|
_cfworker_test_widget.configure(text=t("button.test"))
|
||||||
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
|
worker_test.start(
|
||||||
|
lambda: run_cfworker_multi_test(domains, secure=secure),
|
||||||
|
lambda per: show_multi_connectivity_results(
|
||||||
|
t("connectivity.cfworker_title"), per, label_prefix="DC",
|
||||||
|
parent=frame.winfo_toplevel(),
|
||||||
|
),
|
||||||
|
finish,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
cf_worker_input, text="?", width=28, height=32,
|
||||||
|
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=lambda: webbrowser.open(_get_doc_url("CfWorker")),
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
_cfworker_test_widget = ctk.CTkButton(
|
||||||
|
cf_worker_row, text=t("button.test"), width=56, height=28,
|
||||||
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||||
|
command=_on_cfworker_test,
|
||||||
|
)
|
||||||
|
_cfworker_test_widget.pack(side="right")
|
||||||
|
|
||||||
|
def _sync_cfworker_entry(*_):
|
||||||
|
state = "normal" if cfproxy_worker_enabled_var.get() else "disabled"
|
||||||
|
cf_worker_entry.configure(state=state)
|
||||||
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
|
cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry)
|
||||||
|
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||||
|
_sync_cfworker_entry()
|
||||||
|
return cfproxy_worker_enabled_var, cfproxy_worker_domain_var
|
||||||
|
|
||||||
|
|
||||||
|
def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||||
|
log_inner = create_config_section(ctk, frame, theme, t("section.logs"))
|
||||||
|
|
||||||
|
verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False))
|
||||||
|
verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
||||||
|
verbose_cb.pack(anchor="w", pady=(0, 6))
|
||||||
|
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
||||||
|
|
||||||
|
no_secure_cb = create_checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
|
||||||
|
no_secure_cb.pack(anchor="w", pady=(0, 6))
|
||||||
|
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
|
||||||
|
|
||||||
|
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
||||||
|
adv_frame.pack(fill="x")
|
||||||
|
|
||||||
|
adv_rows = [
|
||||||
|
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
||||||
|
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
||||||
|
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
||||||
|
]
|
||||||
|
advanced_vars = {}
|
||||||
|
for label_text, key, tip in adv_rows:
|
||||||
|
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
||||||
|
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
|
||||||
|
adv_l = create_label(ctk, col, theme, label_text, size=11)
|
||||||
|
adv_l.pack(anchor="w", pady=(0, 2))
|
||||||
|
advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key])))
|
||||||
|
adv_e = create_entry(
|
||||||
|
ctk, col, theme, width=_INNER_W, height=32, radius=8,
|
||||||
|
var=advanced_vars[key],
|
||||||
|
)
|
||||||
|
adv_e.pack(fill="x")
|
||||||
|
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
|
||||||
|
return verbose_var, advanced_vars
|
||||||
|
|
||||||
|
|
||||||
|
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
|
||||||
|
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
|
||||||
|
check_updates_var = ctk.BooleanVar(
|
||||||
|
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||||
|
)
|
||||||
|
upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
||||||
|
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||||
|
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||||
|
|
||||||
|
status_label = create_label(ctk, upd_inner, theme, "", size=11,
|
||||||
|
justify="left", wraplength=_INNER_W)
|
||||||
|
status_label.pack(anchor="w", pady=(0, 8))
|
||||||
|
check_task = BackgroundTask(frame.winfo_toplevel())
|
||||||
|
|
||||||
|
def check():
|
||||||
|
if check_task.running or check_task.closed:
|
||||||
|
return
|
||||||
|
check_button.configure(state="disabled")
|
||||||
|
status_label.configure(text=t("updates.status_checking"))
|
||||||
|
check_task.start(
|
||||||
|
lambda: run_check(__version__), lambda _: refresh(),
|
||||||
|
lambda: check_button.configure(state="normal"),
|
||||||
|
)
|
||||||
|
|
||||||
|
check_button = ctk.CTkButton(
|
||||||
|
upd_inner, text=t("button.check_updates"), height=32,
|
||||||
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", command=check,
|
||||||
|
)
|
||||||
|
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
|
||||||
|
upd_btn_row.pack(fill="x")
|
||||||
|
upd_btn_row.grid_columnconfigure(0, weight=1)
|
||||||
|
release_button = ctk.CTkButton(
|
||||||
|
upd_btn_row, text=t("button.open_release"), height=32,
|
||||||
|
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||||
|
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||||
|
text_color=theme.text_primary, border_width=1,
|
||||||
|
border_color=theme.field_border,
|
||||||
|
)
|
||||||
|
release_button.grid(row=0, column=0, sticky="ew")
|
||||||
|
update_button = ctk.CTkButton(
|
||||||
|
upd_btn_row, text=t("button.update"), height=32,
|
||||||
|
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||||
|
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||||
|
text_color="#ffffff", command=on_update_click,
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh():
|
||||||
|
st = get_status()
|
||||||
|
if st.get("error"):
|
||||||
|
status = t("updates.status_error")
|
||||||
|
elif not st.get("checked"):
|
||||||
|
status = t("updates.status_pending")
|
||||||
|
elif st.get("has_update") and st.get("latest"):
|
||||||
|
status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||||
|
elif st.get("ahead_of_release") and st.get("latest"):
|
||||||
|
status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||||
|
else:
|
||||||
|
status = t("updates.status_latest")
|
||||||
|
status_label.configure(text=status)
|
||||||
|
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
|
release_button.configure(command=lambda: webbrowser.open(rel_url))
|
||||||
|
can_update = bool(st.get("has_update") and on_update_click is not None)
|
||||||
|
upd_btn_row.grid_columnconfigure(1, weight=int(can_update))
|
||||||
|
release_button.grid_configure(padx=(0, 4) if can_update else 0)
|
||||||
|
if can_update:
|
||||||
|
update_button.grid(row=0, column=1, sticky="ew", padx=(4, 0))
|
||||||
|
else:
|
||||||
|
update_button.grid_remove()
|
||||||
|
|
||||||
|
def sync_manual_check(*_):
|
||||||
|
if check_updates_var.get():
|
||||||
|
check_button.pack_forget()
|
||||||
|
else:
|
||||||
|
check_button.pack(fill="x", pady=(0, 8), before=upd_btn_row)
|
||||||
|
|
||||||
|
check_updates_var.trace_add("write", sync_manual_check)
|
||||||
|
sync_manual_check()
|
||||||
|
refresh()
|
||||||
|
return check_updates_var
|
||||||
|
|
||||||
|
|
||||||
|
def _create_autostart(ctk, frame, theme, show_autostart, autostart_value):
|
||||||
|
autostart_var = None
|
||||||
|
if show_autostart:
|
||||||
|
sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
||||||
|
autostart_var = ctk.BooleanVar(master=frame, value=autostart_value)
|
||||||
|
as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
||||||
|
as_cb.pack(anchor="w", pady=(0, 4))
|
||||||
|
as_hint = create_label(
|
||||||
|
ctk, sys_inner, theme,
|
||||||
|
t("label.autostart_hint"),
|
||||||
|
size=11, justify="left", wraplength=_INNER_W,
|
||||||
|
)
|
||||||
|
as_hint.pack(anchor="w")
|
||||||
|
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
||||||
|
return autostart_var
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config_form(
|
||||||
|
widgets: TrayConfigFormWidgets,
|
||||||
|
default_config: dict,
|
||||||
|
*,
|
||||||
|
include_autostart: bool,
|
||||||
|
) -> Union[dict, str]:
|
||||||
|
values = {
|
||||||
|
"host": widgets.host_var.get(),
|
||||||
|
"port": widgets.port_var.get(),
|
||||||
|
"secret": widgets.secret_var.get(),
|
||||||
|
"dc_ip": widgets.dc_textbox.get("1.0", "end"),
|
||||||
|
"verbose": bool(widgets.verbose_var.get()),
|
||||||
|
}
|
||||||
|
for key, var in widgets.advanced_vars.items():
|
||||||
|
values[key] = var.get()
|
||||||
|
for key in (
|
||||||
|
"check_updates", "cfproxy", "h2", "cfproxy_user_domain_enabled",
|
||||||
|
"cfproxy_worker_enabled", "no_secure",
|
||||||
|
):
|
||||||
|
var = getattr(widgets, f"{key}_var")
|
||||||
|
if var is not None:
|
||||||
|
values[key] = bool(var.get())
|
||||||
|
for key in ("cfproxy_user_domain", "cfproxy_worker_domain"):
|
||||||
|
var = getattr(widgets, f"{key}_var")
|
||||||
|
if var is not None:
|
||||||
|
values[key] = var.get()
|
||||||
|
if include_autostart:
|
||||||
|
values["autostart"] = (
|
||||||
|
bool(widgets.autostart_var.get())
|
||||||
|
if widgets.autostart_var is not None else False
|
||||||
|
)
|
||||||
|
if widgets.appearance_var is not None:
|
||||||
|
values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
|
||||||
|
if widgets.language_var is not None:
|
||||||
|
values["language"] = language_from_label(widgets.language_var.get()).value
|
||||||
|
return validate_settings(values, default_config)
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from ui.i18n import detect_system_language
|
from ui.i18n import detect_system_language
|
||||||
|
|
||||||
@@ -20,17 +21,19 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"cfproxy": True,
|
"cfproxy": True,
|
||||||
|
"h2": True,
|
||||||
"cfproxy_user_domain_enabled": False,
|
"cfproxy_user_domain_enabled": False,
|
||||||
"cfproxy_user_domain": [],
|
"cfproxy_user_domain": [],
|
||||||
"cfproxy_worker_enabled": False,
|
"cfproxy_worker_enabled": False,
|
||||||
"cfproxy_worker_domain": [],
|
"cfproxy_worker_domain": [],
|
||||||
"force_test_dc": False,
|
"force_test_dc": False,
|
||||||
"no_secure": False,
|
"no_secure": False,
|
||||||
|
"appearance": "auto",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_tray_config() -> Dict[str, Any]:
|
def default_tray_config() -> Dict[str, Any]:
|
||||||
cfg = dict(_TRAY_DEFAULTS_COMMON)
|
cfg = deepcopy(_TRAY_DEFAULTS_COMMON)
|
||||||
cfg["secret"] = os.urandom(16).hex()
|
cfg["secret"] = os.urandom(16).hex()
|
||||||
cfg["language"] = detect_system_language().value
|
cfg["language"] = detect_system_language().value
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
In-place update on Linux.
|
||||||
|
|
||||||
|
Detects how the app is installed (deb/rpm package or a standalone binary)
|
||||||
|
and updates it without a manual download: packages go through the package
|
||||||
|
manager, a standalone binary is replaced with the release file. Without
|
||||||
|
write access the install runs through pkexec. Unsupported setups (AUR, pip,
|
||||||
|
non-amd64, no pkexec) yield None and the caller shows the release page.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from http.client import HTTPException
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from utils.update_check import download_asset, relaunch_env
|
||||||
|
|
||||||
|
BINARY_ASSET = "TgWsProxy_linux_amd64"
|
||||||
|
|
||||||
|
# install kind -> release asset suffix
|
||||||
|
ASSET_SUFFIX = {"binary": "", "deb": ".deb", "rpm": ".rpm"}
|
||||||
|
|
||||||
|
_INSTALL_TIMEOUT_SEC = 600.0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_quiet(cmd: List[str]) -> bool:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=15,
|
||||||
|
env=relaunch_env(),
|
||||||
|
).returncode == 0
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def exe_path() -> Path:
|
||||||
|
return Path(os.path.realpath(sys.executable))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_root() -> bool:
|
||||||
|
return hasattr(os, "geteuid") and os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _can_elevate() -> bool:
|
||||||
|
return _is_root() or bool(shutil.which("pkexec"))
|
||||||
|
|
||||||
|
|
||||||
|
def _elevate() -> List[str]:
|
||||||
|
return [] if _is_root() else ["pkexec"]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_install_kind(exe: Optional[Path] = None) -> Optional[str]:
|
||||||
|
"""How the app is installed: "deb", "rpm", "binary" or None."""
|
||||||
|
exe = exe or exe_path()
|
||||||
|
if platform.machine().lower() not in ("x86_64", "amd64"):
|
||||||
|
return None # releases ship amd64 only
|
||||||
|
if shutil.which("pacman") and _run_quiet(["pacman", "-Qo", str(exe)]):
|
||||||
|
return None # AUR install, updated by its own package manager
|
||||||
|
|
||||||
|
can_elevate = _can_elevate()
|
||||||
|
if shutil.which("dpkg-query") and _run_quiet(["dpkg-query", "-S", str(exe)]):
|
||||||
|
return "deb" if can_elevate else None
|
||||||
|
if shutil.which("rpm") and _run_quiet(["rpm", "-qf", str(exe)]):
|
||||||
|
return "rpm" if can_elevate else None
|
||||||
|
|
||||||
|
# replacing the binary needs a writable directory or root
|
||||||
|
if os.access(str(exe.parent), os.W_OK) or can_elevate:
|
||||||
|
return "binary"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def asset_name(kind: str) -> str:
|
||||||
|
return BINARY_ASSET + ASSET_SUFFIX[kind]
|
||||||
|
|
||||||
|
|
||||||
|
def install_cmd(kind: str, path: Path) -> List[str]:
|
||||||
|
"""Command that installs the downloaded package."""
|
||||||
|
elevate = _elevate()
|
||||||
|
if kind == "deb":
|
||||||
|
if shutil.which("apt-get"):
|
||||||
|
return elevate + [
|
||||||
|
"apt-get", "install", "-y", str(path),
|
||||||
|
]
|
||||||
|
return elevate + ["dpkg", "-i", str(path)]
|
||||||
|
if kind == "rpm":
|
||||||
|
if shutil.which("dnf"):
|
||||||
|
return elevate + ["dnf", "install", "-y", str(path)]
|
||||||
|
return elevate + ["rpm", "-U", str(path)]
|
||||||
|
raise ValueError("no install command for kind: {0}".format(kind))
|
||||||
|
|
||||||
|
|
||||||
|
def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
|
||||||
|
"""Stage beside the executable so an interrupted copy cannot destroy it."""
|
||||||
|
script = '''set -e
|
||||||
|
staged=$(mktemp "$2.XXXXXX")
|
||||||
|
trap 'rm -f -- "$staged"' EXIT
|
||||||
|
install -m 755 -- "$1" "$staged"
|
||||||
|
mv -fT -- "$staged" "$2"
|
||||||
|
'''
|
||||||
|
return _elevate() + ["sh", "-c", script, "sh", str(src), str(exe)]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]:
|
||||||
|
if log:
|
||||||
|
log.info("Linux update: %s", " ".join(cmd))
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
timeout=_INSTALL_TIMEOUT_SEC,
|
||||||
|
env=relaunch_env(),
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return None
|
||||||
|
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
|
||||||
|
return err[-500:] or "exit code {0}".format(proc.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(
|
||||||
|
kind: str, url: str, digest: str = "",
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Download and install the update.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error text, or None on success.
|
||||||
|
"""
|
||||||
|
exe = exe_path()
|
||||||
|
suffix = ASSET_SUFFIX[kind] or ".new"
|
||||||
|
# with a writable directory download next to the binary and os.replace it
|
||||||
|
in_place = kind == "binary" and os.access(str(exe.parent), os.W_OK)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log.info("Linux update (%s): downloading %s", kind, url)
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
|
||||||
|
tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
|
||||||
|
if kind != "binary":
|
||||||
|
return _run_install(install_cmd(kind, tmp), log=log)
|
||||||
|
if in_place:
|
||||||
|
os.chmod(str(tmp), 0o755)
|
||||||
|
os.replace(str(tmp), str(exe))
|
||||||
|
return None
|
||||||
|
return _run_install(replace_binary_cmd(tmp, exe), log=log)
|
||||||
|
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
|
||||||
|
return str(exc)
|
||||||
+33
-15
@@ -11,6 +11,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
@@ -211,7 +212,7 @@ def load_config() -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Failed to load config: %s", repr(exc))
|
log.warning("Failed to load config: %s", repr(exc))
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
cfg = dict(DEFAULT_CONFIG)
|
cfg = deepcopy(DEFAULT_CONFIG)
|
||||||
_apply_ui_language(cfg)
|
_apply_ui_language(cfg)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -357,6 +358,7 @@ def apply_proxy_config(cfg: dict) -> bool:
|
|||||||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
|
pc.cfproxy_h2_media = cfg.get("h2", DEFAULT_CONFIG["h2"])
|
||||||
cfproxy_user_domains = coerce_domain_list(
|
cfproxy_user_domains = coerce_domain_list(
|
||||||
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
||||||
)
|
)
|
||||||
@@ -458,41 +460,57 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
|||||||
|
|
||||||
# update check
|
# update check
|
||||||
|
|
||||||
def maybe_notify_update(
|
def check_update_async(
|
||||||
cfg: dict,
|
cfg: dict,
|
||||||
is_exiting: Callable[[], bool],
|
is_exiting: Callable[[], bool],
|
||||||
ask_open: Callable[[str, str], bool],
|
on_update: Callable[[str, str], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Check the latest release in the background.
|
||||||
|
|
||||||
|
on_update(version, release_page_url) is called from the worker thread
|
||||||
|
when a newer version is available.
|
||||||
|
"""
|
||||||
if not cfg.get("check_updates", True):
|
if not cfg.get("check_updates", True):
|
||||||
return
|
return
|
||||||
|
|
||||||
def _work():
|
def _work() -> None:
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
if is_exiting():
|
if is_exiting():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
run_check(__version__)
|
run_check(__version__)
|
||||||
st = get_status()
|
st = get_status()
|
||||||
if not st.get("has_update"):
|
if not st.get("has_update") or is_exiting():
|
||||||
return
|
return
|
||||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
on_update(
|
||||||
ver = st.get("latest") or "?"
|
st.get("latest") or "?",
|
||||||
from ui.i18n import t
|
(st.get("html_url") or "").strip() or RELEASES_PAGE_URL,
|
||||||
|
)
|
||||||
if ask_open(
|
|
||||||
t("update.ask_open", version=ver),
|
|
||||||
t("app.update_title"),
|
|
||||||
):
|
|
||||||
webbrowser.open(url)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Update check failed: %s", repr(exc))
|
log.warning("Update check failed: %s", repr(exc))
|
||||||
|
|
||||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_notify_update(
|
||||||
|
cfg: dict,
|
||||||
|
is_exiting: Callable[[], bool],
|
||||||
|
ask_open: Callable[[str, str], bool],
|
||||||
|
) -> None:
|
||||||
|
"""Check for updates and offer to open the release page."""
|
||||||
|
def _on_update(version: str, url: str) -> None:
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from ui.i18n import t
|
||||||
|
|
||||||
|
if ask_open(t("update.ask_open", version=version), t("app.update_title")):
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
check_update_async(cfg, is_exiting, _on_update)
|
||||||
|
|
||||||
|
|
||||||
# ctk thread (windows / linux)
|
# ctk thread (windows / linux)
|
||||||
|
|
||||||
_ctk_root: Any = None
|
_ctk_root: Any = None
|
||||||
|
|||||||
+112
-275
@@ -1,324 +1,161 @@
|
|||||||
"""
|
"""One startup check against GitHub Releases and verified asset downloads."""
|
||||||
Проверка новой версии через GitHub Releases API
|
|
||||||
|
|
||||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
|
||||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import tempfile
|
||||||
|
from contextlib import suppress
|
||||||
|
from http.client import HTTPException
|
||||||
from itertools import zip_longest
|
from itertools import zip_longest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.request import Request
|
from urllib.request import Request
|
||||||
|
|
||||||
from proxy.utils import build_github_opener
|
from proxy.utils import build_github_opener
|
||||||
|
|
||||||
REPO = "Flowseal/tg-ws-proxy"
|
REPO = "Flowseal/tg-ws-proxy"
|
||||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||||
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
|
|
||||||
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
||||||
|
|
||||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
_SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE)
|
||||||
_MIN_FETCH_INTERVAL_SEC = 3600.0
|
|
||||||
|
|
||||||
_state: Dict[str, Any] = {
|
_state: Dict[str, Any] = {
|
||||||
"checked": False,
|
"checked": False,
|
||||||
"has_update": False,
|
"has_update": False,
|
||||||
"ahead_of_release": False,
|
"ahead_of_release": False,
|
||||||
"latest": None,
|
"latest": None,
|
||||||
"html_url": None,
|
"html_url": RELEASES_PAGE_URL,
|
||||||
"error": None,
|
"error": None,
|
||||||
"assets": [],
|
"assets": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _cache_file() -> Optional[Path]:
|
|
||||||
try:
|
|
||||||
from utils.tray_common import APP_DIR
|
|
||||||
root = APP_DIR
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
return root / ".update_check_cache.json"
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cache(path: Optional[Path]) -> Dict[str, Any]:
|
|
||||||
if not path or not path.is_file():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None:
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_version_tuple(s: str) -> tuple:
|
def _parse_version_tuple(s: str) -> tuple:
|
||||||
s = (s or "").strip().lstrip("vV")
|
|
||||||
if not s:
|
|
||||||
return (0,)
|
|
||||||
parts = []
|
parts = []
|
||||||
for seg in s.split("."):
|
for part in (s or "").strip().lstrip("vV").split("."):
|
||||||
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
|
match = re.match(r"\d+", part)
|
||||||
if digits:
|
parts.append(int(match[0]) if match else 0)
|
||||||
try:
|
return tuple(parts)
|
||||||
parts.append(int(digits))
|
|
||||||
except ValueError:
|
|
||||||
parts.append(0)
|
|
||||||
else:
|
|
||||||
parts.append(0)
|
|
||||||
return tuple(parts) if parts else (0,)
|
|
||||||
|
|
||||||
|
|
||||||
def _version_gt(a: str, b: str) -> bool:
|
def _version_gt(a: str, b: str) -> bool:
|
||||||
"""True, если версия a новее b (простое сравнение по сегментам)."""
|
for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0):
|
||||||
ta = _parse_version_tuple(a)
|
if x != y:
|
||||||
tb = _parse_version_tuple(b)
|
return x > y
|
||||||
for x, y in zip_longest(ta, tb, fillvalue=0):
|
|
||||||
if x > y:
|
|
||||||
return True
|
|
||||||
if x < y:
|
|
||||||
return False
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _apply_release_tag(
|
def fetch_latest_release(timeout: float = 12.0) -> dict:
|
||||||
tag: str, html_url: str, current_version: str,
|
req = Request(RELEASES_LATEST_API, headers={
|
||||||
) -> None:
|
|
||||||
global _state
|
|
||||||
if not tag:
|
|
||||||
_state["has_update"] = False
|
|
||||||
_state["ahead_of_release"] = False
|
|
||||||
_state["latest"] = None
|
|
||||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
|
||||||
return
|
|
||||||
latest_clean = tag.lstrip("vV")
|
|
||||||
cur = (current_version or "").strip().lstrip("vV")
|
|
||||||
_state["latest"] = latest_clean
|
|
||||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
|
||||||
_state["has_update"] = _version_gt(latest_clean, cur)
|
|
||||||
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
|
|
||||||
cur, latest_clean
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_latest_release(
|
|
||||||
timeout: float = 12.0,
|
|
||||||
etag: Optional[str] = None,
|
|
||||||
) -> Tuple[Optional[dict], Optional[str], int]:
|
|
||||||
"""
|
|
||||||
GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код).
|
|
||||||
"""
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
"User-Agent": "tg-ws-proxy-update-check",
|
"User-Agent": "tg-ws-proxy-update-check",
|
||||||
}
|
})
|
||||||
if etag:
|
|
||||||
headers["If-None-Match"] = etag
|
|
||||||
req = Request(
|
|
||||||
RELEASES_LATEST_API,
|
|
||||||
headers=headers,
|
|
||||||
method="GET",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||||
code = getattr(resp, "status", None) or resp.getcode()
|
return json.load(resp)
|
||||||
new_etag = resp.headers.get("ETag")
|
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
|
||||||
return json.loads(raw), new_etag, int(code)
|
def _extract_assets(data: dict) -> list:
|
||||||
except HTTPError as e:
|
assets = (data or {}).get("assets") or []
|
||||||
if e.code == 304:
|
if not isinstance(assets, list):
|
||||||
hdrs = e.headers
|
raise ValueError("Invalid release assets from GitHub")
|
||||||
new_etag = hdrs.get("ETag") if hdrs else None
|
return [
|
||||||
return None, new_etag or etag, 304
|
{"name": a["name"], "url": a["browser_download_url"],
|
||||||
raise
|
"digest": a.get("digest") if isinstance(a.get("digest"), str) else ""}
|
||||||
|
for a in assets
|
||||||
|
if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"]
|
||||||
|
and isinstance(a.get("browser_download_url"), str) and a["browser_download_url"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run_check(current_version: str) -> None:
|
def run_check(current_version: str) -> None:
|
||||||
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
|
"""Publish the complete result at once; no persistent cache or error cooldown."""
|
||||||
global _state
|
global _state
|
||||||
_state["checked"] = True
|
state = {
|
||||||
_state["error"] = None
|
"checked": True, "has_update": False, "ahead_of_release": False,
|
||||||
|
"latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [],
|
||||||
cache_path = _cache_file()
|
|
||||||
cache = _load_cache(cache_path)
|
|
||||||
now = time.time()
|
|
||||||
last_attempt = float(cache.get("last_attempt_at") or 0)
|
|
||||||
|
|
||||||
if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC:
|
|
||||||
tag = (cache.get("tag_name") or "").strip()
|
|
||||||
if tag:
|
|
||||||
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
|
|
||||||
_state["assets"] = cache.get("assets") or []
|
|
||||||
return
|
|
||||||
err = cache.get("last_error")
|
|
||||||
_state["error"] = (
|
|
||||||
err if err else "Проверка обновлений отложена (интервал между запросами)."
|
|
||||||
)
|
|
||||||
_state["has_update"] = False
|
|
||||||
_state["ahead_of_release"] = False
|
|
||||||
_state["latest"] = None
|
|
||||||
_state["html_url"] = RELEASES_PAGE_URL
|
|
||||||
return
|
|
||||||
|
|
||||||
etag = (cache.get("etag") or "").strip() or None
|
|
||||||
try:
|
|
||||||
data, new_etag, code = fetch_latest_release(etag=etag)
|
|
||||||
cache["last_attempt_at"] = now
|
|
||||||
if code == 304:
|
|
||||||
tag = (cache.get("tag_name") or "").strip()
|
|
||||||
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
|
||||||
_apply_release_tag(tag, url, current_version)
|
|
||||||
_state["assets"] = cache.get("assets") or []
|
|
||||||
if new_etag:
|
|
||||||
cache["etag"] = new_etag
|
|
||||||
_save_cache(cache_path, cache)
|
|
||||||
return
|
|
||||||
|
|
||||||
assert data is not None
|
|
||||||
tag = (data.get("tag_name") or "").strip()
|
|
||||||
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
|
||||||
if not tag:
|
|
||||||
_state["has_update"] = False
|
|
||||||
_state["ahead_of_release"] = False
|
|
||||||
_state["latest"] = None
|
|
||||||
_state["html_url"] = html_url
|
|
||||||
else:
|
|
||||||
_apply_release_tag(tag, html_url, current_version)
|
|
||||||
if new_etag:
|
|
||||||
cache["etag"] = new_etag
|
|
||||||
cache["tag_name"] = tag
|
|
||||||
cache["html_url"] = html_url
|
|
||||||
assets = [
|
|
||||||
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
|
||||||
for a in (data.get("assets") or [])
|
|
||||||
if a.get("name") and a.get("browser_download_url")
|
|
||||||
]
|
|
||||||
_state["assets"] = assets
|
|
||||||
cache["assets"] = assets
|
|
||||||
cache.pop("last_error", None)
|
|
||||||
_save_cache(cache_path, cache)
|
|
||||||
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
|
|
||||||
cache["last_attempt_at"] = now
|
|
||||||
msg = str(e)
|
|
||||||
if isinstance(e, HTTPError) and e.code == 403:
|
|
||||||
msg = (
|
|
||||||
"GitHub API вернул 403 (лимит или доступ). Повторите позже."
|
|
||||||
)
|
|
||||||
cache["last_error"] = msg
|
|
||||||
_save_cache(cache_path, cache)
|
|
||||||
_state["error"] = msg
|
|
||||||
_state["has_update"] = False
|
|
||||||
_state["ahead_of_release"] = False
|
|
||||||
_state["latest"] = None
|
|
||||||
_state["html_url"] = RELEASES_PAGE_URL
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_release_by_tag(
|
|
||||||
tag: str, timeout: float = 12.0,
|
|
||||||
) -> Tuple[Optional[dict], int]:
|
|
||||||
if not tag:
|
|
||||||
return None, 0
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"User-Agent": "tg-ws-proxy-update-check",
|
|
||||||
}
|
}
|
||||||
req = Request(
|
|
||||||
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
|
|
||||||
headers=headers,
|
|
||||||
method="GET",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
data = fetch_latest_release()
|
||||||
code = getattr(resp, "status", None) or resp.getcode()
|
if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str):
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
raise ValueError("Invalid release from GitHub")
|
||||||
return json.loads(raw), int(code)
|
latest = data["tag_name"].strip().lstrip("vV")
|
||||||
except HTTPError as e:
|
if not latest or not latest[0].isdigit():
|
||||||
if e.code in [304, 404]:
|
raise ValueError("Invalid release version from GitHub")
|
||||||
return None, e.code
|
state.update(
|
||||||
|
latest=latest,
|
||||||
|
has_update=_version_gt(latest, current_version),
|
||||||
|
ahead_of_release=_version_gt(current_version, latest),
|
||||||
|
html_url=data.get("html_url") or RELEASES_PAGE_URL,
|
||||||
|
assets=_extract_assets(data),
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, HTTPException) as exc:
|
||||||
|
state["error"] = str(exc) or type(exc).__name__
|
||||||
|
logging.getLogger(__name__).warning("Update check failed: %s", exc)
|
||||||
|
_state = state
|
||||||
|
|
||||||
|
|
||||||
|
def download_asset(
|
||||||
|
url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "",
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> Path:
|
||||||
|
"""Return a temporary file only after its mandatory SHA-256 has matched."""
|
||||||
|
expected = _SHA256.fullmatch(digest if isinstance(digest, str) else "")
|
||||||
|
if not expected:
|
||||||
|
raise ValueError("The release asset has no valid SHA-256 checksum")
|
||||||
|
|
||||||
|
fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix, prefix="TgWsProxy_tmp_")
|
||||||
|
path = Path(name)
|
||||||
|
try:
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
sha.update(chunk)
|
||||||
|
if sha.hexdigest() != expected[1].lower():
|
||||||
|
raise OSError("SHA-256 checksum does not match the release asset")
|
||||||
|
return path
|
||||||
|
except BaseException:
|
||||||
|
with suppress(OSError):
|
||||||
|
path.unlink()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _extract_assets(data: Optional[dict]) -> list:
|
def find_asset(name: str) -> Optional[Dict[str, str]]:
|
||||||
if not data:
|
"""Find an asset suitable for automatic installation."""
|
||||||
return []
|
for asset in _state["assets"]:
|
||||||
return [
|
if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]):
|
||||||
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
return dict(asset)
|
||||||
for a in (data.get("assets") or [])
|
return None
|
||||||
if a.get("name") and a.get("browser_download_url")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_status() -> Dict[str, Any]:
|
def get_status() -> Dict[str, Any]:
|
||||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
state = _state
|
||||||
return dict(_state)
|
return {**state, "assets": [dict(a) for a in state["assets"]]}
|
||||||
|
|
||||||
|
|
||||||
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
def relaunch_env() -> Dict[str, str]:
|
||||||
new_assets = _state.get("assets") or []
|
"""Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6."""
|
||||||
if not new_assets:
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))}
|
||||||
return None
|
mei = getattr(sys, "_MEIPASS", None)
|
||||||
|
if mei:
|
||||||
target_name = None
|
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||||
|
for var in ("PATH", "LD_LIBRARY_PATH", "GI_TYPELIB_PATH", "GTK_PATH",
|
||||||
# SHA256 match
|
"GST_PLUGIN_PATH", "GIO_MODULE_DIR"):
|
||||||
try:
|
original = env.pop(var + "_ORIG", None)
|
||||||
import hashlib
|
if original is not None:
|
||||||
data, code = fetch_release_by_tag(f"v{current_version}")
|
env[var] = original
|
||||||
if code == 200 and data:
|
elif mei and env.get(var):
|
||||||
cur_assets = _extract_assets(data)
|
bundle = os.path.normcase(os.path.abspath(mei))
|
||||||
if cur_assets:
|
kept = [p for p in env[var].split(os.pathsep)
|
||||||
h = hashlib.sha256()
|
if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)]
|
||||||
with open(exe_path, "rb") as f:
|
if kept:
|
||||||
while True:
|
env[var] = os.pathsep.join(kept)
|
||||||
chunk = f.read(65536)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
h.update(chunk)
|
|
||||||
exe_sha = h.hexdigest().lower()
|
|
||||||
for a in cur_assets:
|
|
||||||
d = (a.get("digest") or "").lower()
|
|
||||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
|
||||||
target_name = a["name"]
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback
|
|
||||||
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
|
||||||
import platform
|
|
||||||
import struct
|
|
||||||
|
|
||||||
is_64 = struct.calcsize("P") * 8 == 64
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
is_arm64 = machine in ("arm64", "aarch64")
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_modern = sys.getwindowsversion().major >= 10
|
|
||||||
except Exception:
|
|
||||||
is_modern = True
|
|
||||||
|
|
||||||
if is_arm64:
|
|
||||||
target_name = "TgWsProxy_windows_arm64.exe"
|
|
||||||
elif is_modern:
|
|
||||||
target_name = "TgWsProxy_windows.exe"
|
|
||||||
elif is_64:
|
|
||||||
target_name = "TgWsProxy_windows_7_64bit.exe"
|
|
||||||
else:
|
else:
|
||||||
target_name = "TgWsProxy_windows_7_32bit.exe"
|
env.pop(var, None)
|
||||||
|
return env
|
||||||
for a in new_assets:
|
|
||||||
if a.get("name") == target_name:
|
|
||||||
return a["url"], a["name"]
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Windows build selection and executable replacement with rollback."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from utils.update_check import download_asset, relaunch_env
|
||||||
|
|
||||||
|
|
||||||
|
def asset_name() -> Optional[str]:
|
||||||
|
# Use the running build, including x86/x64 emulation and renamed executables.
|
||||||
|
target = sysconfig.get_platform()
|
||||||
|
if target == "win-arm64":
|
||||||
|
return "TgWsProxy_windows_arm64.exe"
|
||||||
|
if target == "win32":
|
||||||
|
return "TgWsProxy_windows_7_32bit.exe"
|
||||||
|
if target == "win-amd64":
|
||||||
|
return "TgWsProxy_windows_7_64bit.exe" if sys.version_info < (3, 9) else "TgWsProxy_windows.exe"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(asset: dict, before_restart: Callable[[], None]) -> None:
|
||||||
|
"""Verify, replace and launch; keep the old executable if any step fails."""
|
||||||
|
exe = Path(sys.executable)
|
||||||
|
backup = exe.with_name(exe.stem + "_oldtgws.exe")
|
||||||
|
downloaded = download_asset(asset["url"], exe.parent, digest=asset["digest"])
|
||||||
|
try:
|
||||||
|
backup.unlink(missing_ok=True)
|
||||||
|
exe.rename(backup)
|
||||||
|
try:
|
||||||
|
downloaded.replace(exe)
|
||||||
|
before_restart()
|
||||||
|
bundle = getattr(sys, "_MEIPASS", None)
|
||||||
|
if bundle:
|
||||||
|
ctypes.windll.kernel32.SetDllDirectoryW(None)
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(exe), *sys.argv[1:]], env=relaunch_env(),
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if bundle:
|
||||||
|
ctypes.windll.kernel32.SetDllDirectoryW(ctypes.c_wchar_p(bundle))
|
||||||
|
except BaseException:
|
||||||
|
backup.replace(exe)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
with suppress(OSError):
|
||||||
|
downloaded.unlink(missing_ok=True)
|
||||||
+53
-185
@@ -2,17 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import winreg
|
import winreg
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from proxy.utils import build_github_opener
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyperclip
|
import pyperclip
|
||||||
@@ -42,24 +39,23 @@ from utils.win32_theme import (
|
|||||||
)
|
)
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
acquire_lock, bootstrap, check_ipv6_warning, check_update_async, ctk_run_dialog,
|
||||||
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
||||||
quit_ctk, release_lock, restart_proxy,
|
quit_ctk, release_lock, restart_proxy,
|
||||||
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||||
)
|
)
|
||||||
from utils.update_check import (
|
from utils.update_check import find_asset, get_status, RELEASES_PAGE_URL
|
||||||
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
|
from utils.windows_update import apply_update, asset_name
|
||||||
)
|
|
||||||
from ui.ctk_tray_ui import (
|
from ui.ctk_tray_ui import (
|
||||||
install_tray_config_buttons, install_tray_config_form,
|
install_tray_config_buttons, install_tray_config_form,
|
||||||
populate_first_run_window, tray_settings_scroll_and_footer,
|
populate_first_run_window, tray_settings_scroll_and_footer,
|
||||||
validate_config_form,
|
|
||||||
)
|
)
|
||||||
from ui.ctk_theme import (
|
from ui.ctk_theme import (
|
||||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||||
)
|
)
|
||||||
from ui.i18n import set_language, t
|
from ui.i18n import t
|
||||||
|
from ui.settings_dialog import SettingsDialog
|
||||||
|
|
||||||
_tray_icon: Optional[object] = None
|
_tray_icon: Optional[object] = None
|
||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
@@ -128,14 +124,16 @@ def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def update_ctk_form(
|
def update_ctk_form(
|
||||||
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
|
text: str, title: Optional[str] = None, asset: Optional[dict] = None,
|
||||||
release_url: Optional[str] = None,
|
release_url: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
title = title or t("app.name")
|
title = title or t("app.name")
|
||||||
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||||
|
if not asset:
|
||||||
|
return "open" if _ask_yes_no(t("update.ask_open", version=get_status()["latest"]), title) else "close"
|
||||||
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
||||||
if result == _IDYES:
|
if result == _IDYES:
|
||||||
return "update"
|
_perform_update(asset)
|
||||||
if result == _IDNO:
|
if result == _IDNO:
|
||||||
return "open"
|
return "open"
|
||||||
return "close"
|
return "close"
|
||||||
@@ -147,10 +145,10 @@ def update_ctk_form(
|
|||||||
root = create_ctk_toplevel(
|
root = create_ctk_toplevel(
|
||||||
ctk,
|
ctk,
|
||||||
title=title,
|
title=title,
|
||||||
width=310 if IS_FROZEN else 210,
|
width=310 if asset else 210,
|
||||||
height=130 if IS_FROZEN else 100,
|
height=130 if asset else 100,
|
||||||
theme=theme,
|
theme=theme,
|
||||||
topmost=False,
|
topmost=True,
|
||||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||||
)
|
)
|
||||||
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
|
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
|
||||||
@@ -185,21 +183,16 @@ def update_ctk_form(
|
|||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
def _on_update() -> None:
|
def _on_update() -> None:
|
||||||
if not download_url:
|
|
||||||
if release_url:
|
|
||||||
webbrowser.open(release_url)
|
|
||||||
_close_with("open")
|
|
||||||
return
|
|
||||||
for b in btns:
|
for b in btns:
|
||||||
b.configure(state="disabled")
|
b.configure(state="disabled")
|
||||||
root.protocol("WM_DELETE_WINDOW", lambda: None)
|
root.protocol("WM_DELETE_WINDOW", lambda: None)
|
||||||
def _run():
|
def _run():
|
||||||
_perform_update(download_url, set_status=_set_status)
|
_perform_update(asset, set_status=_set_status)
|
||||||
root.after(0, lambda: [b.configure(state="normal") for b in btns])
|
root.after(0, lambda: [b.configure(state="normal") for b in btns])
|
||||||
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
|
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
if IS_FROZEN:
|
if asset:
|
||||||
btn_upd = ctk.CTkButton(
|
btn_upd = ctk.CTkButton(
|
||||||
row, text=t("button.update"), width=88, height=34,
|
row, text=t("button.update"), width=88, height=34,
|
||||||
font=(theme.ui_font_family, 13), command=_on_update,
|
font=(theme.ui_font_family, 13), command=_on_update,
|
||||||
@@ -228,97 +221,37 @@ def update_ctk_form(
|
|||||||
return result["value"]
|
return result["value"]
|
||||||
|
|
||||||
|
|
||||||
def _perform_update(download_url: str, set_status=None) -> None:
|
def _perform_update(asset: dict, set_status=None) -> None:
|
||||||
|
restarting = False
|
||||||
|
|
||||||
def _step(msg: str) -> None:
|
def _step(msg: str) -> None:
|
||||||
log.info("Update: %s", msg)
|
log.info("Update: %s", msg)
|
||||||
if set_status:
|
if set_status:
|
||||||
set_status(msg)
|
set_status(msg)
|
||||||
time.sleep(0.8)
|
|
||||||
|
|
||||||
def _err(msg: str) -> None:
|
def _before_restart() -> None:
|
||||||
log.error("Update error: %s", msg)
|
nonlocal restarting
|
||||||
if set_status:
|
_step(t("update.restarting"))
|
||||||
set_status(f"{t('update.error', msg=msg)}")
|
restarting = True
|
||||||
else:
|
stop_proxy()
|
||||||
_show_error(msg)
|
release_lock()
|
||||||
|
_release_win_mutex()
|
||||||
|
|
||||||
_step(t("update.downloading"))
|
_step(t("update.downloading"))
|
||||||
cur_exe = Path(sys.executable)
|
|
||||||
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
|
|
||||||
tmp_path = None
|
|
||||||
try:
|
try:
|
||||||
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
|
apply_update(asset, _before_restart)
|
||||||
os.close(fd)
|
|
||||||
tmp_path = Path(tmp_name)
|
|
||||||
log.info("Downloading update from %s", download_url)
|
|
||||||
opener = build_github_opener()
|
|
||||||
with opener.open(download_url) as _resp:
|
|
||||||
with open(str(tmp_path), "wb") as _fout:
|
|
||||||
while True:
|
|
||||||
_chunk = _resp.read(65536)
|
|
||||||
if not _chunk:
|
|
||||||
break
|
|
||||||
_fout.write(_chunk)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_err(t("update.download_fail", error=exc))
|
log.exception("Update failed")
|
||||||
if tmp_path:
|
if restarting:
|
||||||
try:
|
mutex = _acquire_win_mutex()
|
||||||
tmp_path.unlink(missing_ok=True)
|
if mutex is True or mutex is None and acquire_lock():
|
||||||
except OSError:
|
start_proxy(_config, _show_error)
|
||||||
pass
|
msg = t("update.error", msg=exc)
|
||||||
|
if set_status:
|
||||||
|
set_status(msg)
|
||||||
|
else:
|
||||||
|
_show_error(msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
_step(t("update.replacing"))
|
|
||||||
try:
|
|
||||||
if old_exe.exists():
|
|
||||||
old_exe.unlink()
|
|
||||||
cur_exe.rename(old_exe)
|
|
||||||
except Exception as exc:
|
|
||||||
_err(t("update.rename_fail", error=exc))
|
|
||||||
try:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
tmp_path.rename(cur_exe)
|
|
||||||
except Exception as exc:
|
|
||||||
_err(t("update.move_fail", error=exc))
|
|
||||||
try:
|
|
||||||
old_exe.rename(cur_exe)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
_step(t("update.restarting"))
|
|
||||||
_release_win_mutex()
|
|
||||||
stop_proxy()
|
|
||||||
|
|
||||||
# Don't reuse existing _MEI* dir
|
|
||||||
env = os.environ.copy()
|
|
||||||
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
|
|
||||||
del env[_k]
|
|
||||||
if hasattr(sys, "_MEIPASS"):
|
|
||||||
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
|
|
||||||
env["PATH"] = os.pathsep.join(
|
|
||||||
p for p in env.get("PATH", "").split(os.pathsep)
|
|
||||||
if os.path.normcase(p.rstrip("\\/")) != _mei
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
[str(cur_exe)],
|
|
||||||
env=env,
|
|
||||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log.error("Failed to launch updated exe: %s", exc)
|
|
||||||
time.sleep(0.5)
|
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -339,13 +272,15 @@ def _trigger_update_flow(icon=None, item=None) -> None:
|
|||||||
st = get_status()
|
st = get_status()
|
||||||
if not st.get("has_update"):
|
if not st.get("has_update"):
|
||||||
return
|
return
|
||||||
|
if _tray_icon is not None:
|
||||||
|
_tray_icon.menu = _build_menu()
|
||||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||||
ver = st.get("latest") or "?"
|
ver = st.get("latest") or "?"
|
||||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
asset = find_asset(asset_name()) if IS_FROZEN else None
|
||||||
|
|
||||||
choice = update_ctk_form(
|
choice = update_ctk_form(
|
||||||
t("update.available", version=ver),
|
t("update.available", version=ver),
|
||||||
download_url=asset[0] if asset else None,
|
asset=asset,
|
||||||
release_url=url,
|
release_url=url,
|
||||||
)
|
)
|
||||||
if choice == "open":
|
if choice == "open":
|
||||||
@@ -358,27 +293,6 @@ def _trigger_update_flow(icon=None, item=None) -> None:
|
|||||||
threading.Thread(target=_show, daemon=True, name="manual-update").start()
|
threading.Thread(target=_show, daemon=True, name="manual-update").start()
|
||||||
|
|
||||||
|
|
||||||
def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
|
||||||
if not cfg.get("check_updates", True):
|
|
||||||
return
|
|
||||||
|
|
||||||
def _work():
|
|
||||||
time.sleep(1.5)
|
|
||||||
if is_exiting():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
run_check(__version__)
|
|
||||||
if _tray_icon is not None:
|
|
||||||
_tray_icon.menu = _build_menu()
|
|
||||||
if is_exiting():
|
|
||||||
return
|
|
||||||
_trigger_update_flow()
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Update check failed: %s", repr(exc))
|
|
||||||
|
|
||||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
|
||||||
|
|
||||||
|
|
||||||
# autostart (registry)
|
# autostart (registry)
|
||||||
|
|
||||||
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
@@ -500,8 +414,6 @@ def _edit_config_dialog() -> None:
|
|||||||
|
|
||||||
cfg = dict(_config)
|
cfg = dict(_config)
|
||||||
cfg["autostart"] = is_autostart_enabled()
|
cfg["autostart"] = is_autostart_enabled()
|
||||||
if _supports_autostart() and not cfg["autostart"]:
|
|
||||||
set_autostart_enabled(False)
|
|
||||||
|
|
||||||
def _build(done: threading.Event) -> None:
|
def _build(done: threading.Event) -> None:
|
||||||
theme = ctk_theme_for_platform()
|
theme = ctk_theme_for_platform()
|
||||||
@@ -521,72 +433,29 @@ def _edit_config_dialog() -> None:
|
|||||||
if _tray_icon is not None:
|
if _tray_icon is not None:
|
||||||
_tray_icon.menu = _build_menu()
|
_tray_icon.menu = _build_menu()
|
||||||
|
|
||||||
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
|
|
||||||
|
|
||||||
widgets = install_tray_config_form(
|
widgets = install_tray_config_form(
|
||||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||||
show_autostart=_supports_autostart(),
|
show_autostart=_supports_autostart(),
|
||||||
autostart_value=cfg.get("autostart", False),
|
autostart_value=cfg.get("autostart", False),
|
||||||
on_language_change=_refresh_tray_menu,
|
|
||||||
on_update_click=_trigger_update_flow,
|
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:
|
def _finish() -> None:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
def _cancel() -> None:
|
dialog = SettingsDialog(
|
||||||
ctk.set_appearance_mode(_original_appearance)
|
ctk=ctk, root=root, widgets=widgets, config=_config,
|
||||||
_restore_ui_locale()
|
defaults=DEFAULT_CONFIG, persist=save_config,
|
||||||
_finish()
|
refresh_menu=_refresh_tray_menu, finish=_finish,
|
||||||
|
restart=lambda config: restart_proxy(config, _show_error),
|
||||||
def on_save() -> None:
|
include_autostart=_supports_autostart(),
|
||||||
from tkinter import messagebox
|
apply_autostart=set_autostart_enabled if _supports_autostart() else None,
|
||||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
read_autostart=is_autostart_enabled if _supports_autostart() else None,
|
||||||
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)
|
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel)
|
||||||
|
|
||||||
ctk_run_dialog(_build)
|
ctk_run_dialog(_build)
|
||||||
|
|
||||||
@@ -679,7 +548,7 @@ def run_tray() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
start_proxy(_config, _show_error)
|
start_proxy(_config, _show_error)
|
||||||
_maybe_do_update(_config, lambda: _exiting)
|
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
|
||||||
_show_first_run()
|
_show_first_run()
|
||||||
check_ipv6_warning(_show_info)
|
check_ipv6_warning(_show_info)
|
||||||
|
|
||||||
@@ -698,12 +567,11 @@ def main() -> None:
|
|||||||
|
|
||||||
if IS_FROZEN:
|
if IS_FROZEN:
|
||||||
def _cleanup_old_exes():
|
def _cleanup_old_exes():
|
||||||
exe_dir = Path(sys.executable).parent
|
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
for _f in exe_dir.glob("*_oldtgws.exe"):
|
exe = Path(sys.executable)
|
||||||
|
backup = exe.with_name(exe.stem + "_oldtgws.exe")
|
||||||
try:
|
try:
|
||||||
_f.unlink()
|
backup.unlink(missing_ok=True)
|
||||||
log.info("Deleted leftover: %s", _f)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
|
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
|
||||||
|
|||||||
Reference in new issue
Block a user