mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-17 20:18:28 +03:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12fafbc8f4 | ||
|
|
5839ca2564 | ||
|
|
e40c571009 | ||
|
|
96e5b4b639 | ||
|
|
13d2b1db6d | ||
|
|
a29a1a8610 | ||
|
|
94010f1481 | ||
|
|
42172235c7 | ||
|
|
b0010af130 | ||
|
|
784a7f659b | ||
|
|
21fe672963 | ||
|
|
ed46ecce5a | ||
|
|
9562b11101 | ||
|
|
dfdb993da5 | ||
|
|
d4f8b51326 | ||
|
|
ca431633d7 | ||
|
|
ea4e8e790a | ||
|
|
05d6de269b | ||
|
|
1c4b103df2 | ||
|
|
23f0e4d426 | ||
|
|
49e62ca142 | ||
|
|
5915a0e1f3 | ||
|
|
7bc9e133c8 | ||
|
|
12d3d5e478 | ||
|
|
b7cca232ea | ||
|
|
0eebdff69e | ||
|
|
ab3bec967c | ||
|
|
a16f7dfc0b | ||
|
|
6f02fc1c46 | ||
|
|
884fffcc2f | ||
|
|
09ce00b2e0 | ||
|
|
362c5a4893 | ||
|
|
bff67b3ecf | ||
|
|
d5abfbf9c2 | ||
|
|
8269ebe3bb | ||
|
|
3770569789 | ||
|
|
e72a44d74b | ||
|
|
33d3147c0b |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: 🐛 Проблема
|
name: 🐛 Проблема
|
||||||
title: '[Проблема] '
|
title: '[Проблема] '
|
||||||
description: Сообщить о проблеме
|
description: Сообщить о проблеме
|
||||||
labels: ['type: проблема', 'status: нуждается в сортировке']
|
labels: ['bug']
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: 🚀 Предложение
|
name: 🚀 Предложение
|
||||||
title: '[Предложение] '
|
title: '[Предложение] '
|
||||||
description: Предложить улучшение или новую функциональность
|
description: Предложить улучшение или новую функциональность
|
||||||
labels: ['type: предложение', 'status: нуждается в сортировке']
|
labels: ['enhancement']
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
5
.github/cfproxy-domains.txt
vendored
5
.github/cfproxy-domains.txt
vendored
@@ -8,3 +8,8 @@ clngqrflngqin.com
|
|||||||
tjacxbqtj.com
|
tjacxbqtj.com
|
||||||
bxaxtxmrw.com
|
bxaxtxmrw.com
|
||||||
dmohrsgmohcrwb.com
|
dmohrsgmohcrwb.com
|
||||||
|
vwbmtmoi.com
|
||||||
|
khgrre.com
|
||||||
|
ulihssf.com
|
||||||
|
tmhqsdqmfpmk.com
|
||||||
|
xwuwoqbm.com
|
||||||
|
|||||||
87
.github/workflows/build.yml
vendored
87
.github/workflows/build.yml
vendored
@@ -17,7 +17,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows-x64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -73,9 +73,85 @@ jobs:
|
|||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TgWsProxy
|
name: TgWsProxy-windows-x64
|
||||||
path: dist/TgWsProxy_windows.exe
|
path: dist/TgWsProxy_windows.exe
|
||||||
|
|
||||||
|
build-windows-arm64:
|
||||||
|
runs-on: windows-11-arm
|
||||||
|
env:
|
||||||
|
CRYPTOGRAPHY_VERSION: "46.0.5"
|
||||||
|
ARM64_WHEELHOUSE: wheelhouse-arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
architecture: arm64
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Restore ARM64 cryptography wheel
|
||||||
|
id: cryptography-wheel-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.ARM64_WHEELHOUSE }}
|
||||||
|
key: windows-arm64-py311-cryptography-${{ env.CRYPTOGRAPHY_VERSION }}-${{ hashFiles('pyproject.toml', '.github/workflows/build.yml') }}
|
||||||
|
|
||||||
|
- name: Install ARM64 OpenSSL
|
||||||
|
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
vcpkg install openssl:arm64-windows-static
|
||||||
|
$opensslDir = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static"
|
||||||
|
"OPENSSL_DIR=$opensslDir" >> $env:GITHUB_ENV
|
||||||
|
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
|
||||||
|
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build ARM64 cryptography wheel
|
||||||
|
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
mkdir $env:ARM64_WHEELHOUSE
|
||||||
|
pip wheel --no-deps --wheel-dir $env:ARM64_WHEELHOUSE "cryptography==$env:CRYPTOGRAPHY_VERSION"
|
||||||
|
|
||||||
|
- name: Install dependencies & pyinstaller
|
||||||
|
run: pip install --find-links $env:ARM64_WHEELHOUSE . "pyinstaller==6.13.0"
|
||||||
|
|
||||||
|
- name: Build EXE with PyInstaller
|
||||||
|
run: pyinstaller packaging/windows.spec --noconfirm
|
||||||
|
|
||||||
|
- name: Strip Rich PE header
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -c "
|
||||||
|
import struct, pathlib
|
||||||
|
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||||
|
data = bytearray(exe.read_bytes())
|
||||||
|
rich = data.find(b'Rich')
|
||||||
|
if rich == -1:
|
||||||
|
print('Rich header not found, skipping')
|
||||||
|
raise SystemExit(0)
|
||||||
|
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||||
|
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||||
|
ds = data.find(dans)
|
||||||
|
if ds == -1:
|
||||||
|
print('DanS marker not found, skipping')
|
||||||
|
raise SystemExit(0)
|
||||||
|
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||||
|
exe.write_bytes(data)
|
||||||
|
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Rename artifact
|
||||||
|
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: TgWsProxy-windows-arm64
|
||||||
|
path: dist/TgWsProxy_windows_arm64.exe
|
||||||
|
|
||||||
build-win7:
|
build-win7:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
@@ -439,7 +515,7 @@ jobs:
|
|||||||
dist/TgWsProxy_linux_amd64.rpm
|
dist/TgWsProxy_linux_amd64.rpm
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-windows, build-win7, build-macos, build-linux]
|
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.inputs.make_release == 'true' }}
|
if: ${{ github.event.inputs.make_release == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
@@ -457,8 +533,13 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
##
|
##
|
||||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Не можете скачать?
|
||||||
|
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
|
||||||
files: |
|
files: |
|
||||||
dist/TgWsProxy_windows.exe
|
dist/TgWsProxy_windows.exe
|
||||||
|
dist/TgWsProxy_windows_arm64.exe
|
||||||
dist/TgWsProxy_windows_7_64bit.exe
|
dist/TgWsProxy_windows_7_64bit.exe
|
||||||
dist/TgWsProxy_windows_7_32bit.exe
|
dist/TgWsProxy_windows_7_32bit.exe
|
||||||
dist/TgWsProxy_macos_universal.dmg
|
dist/TgWsProxy_macos_universal.dmg
|
||||||
|
|||||||
1
.github/workflows/issue-triage.yml
vendored
1
.github/workflows/issue-triage.yml
vendored
@@ -9,6 +9,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
comment:
|
comment:
|
||||||
|
if: contains(github.event.issue.labels.*.name, 'bug')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Comment on new issue
|
- name: Comment on new issue
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
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="" \
|
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=""
|
||||||
|
|
||||||
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 \
|
||||||
@@ -42,5 +43,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; 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; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||||
CMD []
|
CMD []
|
||||||
@@ -47,8 +47,8 @@ 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) |
|
||||||
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare. [Подробнее](./CfProxy.md) |
|
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
|
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--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`) |
|
||||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||||
|
|||||||
124
docs/CfWorker.md
Normal file
124
docs/CfWorker.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# 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`
|
||||||
|
<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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
70
docs/README.docker.md
Normal file
70
docs/README.docker.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# TG WS Proxy для Docker
|
||||||
|
|
||||||
|
## Установка из исходников
|
||||||
|
|
||||||
|
Вводите команды последовательно, одну за другой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачиваем репозиторий
|
||||||
|
git clone https://github.com/Flowseal/tg-ws-proxy.git
|
||||||
|
|
||||||
|
# Переходим в папку с проектом
|
||||||
|
cd tg-ws-proxy
|
||||||
|
|
||||||
|
# Собираем образ
|
||||||
|
docker build -t tg-ws-proxy .
|
||||||
|
|
||||||
|
# Запускаем контейнер
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
|
||||||
|
# Получаем ссылку для подключения
|
||||||
|
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
|
||||||
|
```
|
||||||
|
|
||||||
|
После выполнения последней команды вы увидите ссылку вида:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка параметров
|
||||||
|
|
||||||
|
Все настройки задаются переменными окружения при запуске контейнера:
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
| ----------------------- | --------------------------------- | ------------------------------------- |
|
||||||
|
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
|
||||||
|
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
|
||||||
|
| `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_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
||||||
|
|
||||||
|
Пример с ручным указанием секрета:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name tg-ws-proxy \
|
||||||
|
--restart=always \
|
||||||
|
-p 1443:1443 \
|
||||||
|
-e TG_WS_PROXY_SECRET="ваш_секрет" \
|
||||||
|
tg-ws-proxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Для генерации секрета можно использовать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка Telegram Desktop
|
||||||
|
|
||||||
|
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||||
|
2. Добавьте прокси:
|
||||||
|
- **Тип:** MTProto
|
||||||
|
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||||
|
- **Порт:** `1443` (или переопределенный вами)
|
||||||
|
- **Secret:** из настроек или логов
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
<div align="center">
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> ### [🎉 Поддержать меня](./Funding.md)
|
> ### [🎉 Поддержать меня](./Funding.md)
|
||||||
@@ -24,8 +33,8 @@
|
|||||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="https://github.com/user-attachments/assets/17f1d15e-e1c2-41ea-a452-220d13359262" media="(prefers-color-scheme: dark)">
|
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
|
||||||
<img src="https://github.com/user-attachments/assets/8d595468-83a1-4e4f-bac4-9ce4a07027bd">
|
<img src="./images/preview-white.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
## Навигация
|
## Навигация
|
||||||
@@ -34,6 +43,8 @@
|
|||||||
- **[Windows](./README.windows.md)**
|
- **[Windows](./README.windows.md)**
|
||||||
- **[macOS](./README.macos.md)**
|
- **[macOS](./README.macos.md)**
|
||||||
- **[Linux](./README.linux.md)**
|
- **[Linux](./README.linux.md)**
|
||||||
|
- **[Docker](./README.docker.md)**
|
||||||
|
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
|
||||||
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
|
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
|
||||||
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
||||||
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
||||||
@@ -44,7 +55,8 @@
|
|||||||
|
|
||||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
- `TgWsProxy_windows.exe` (Windows 10+)
|
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||||
|
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||||
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
@@ -105,7 +117,8 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
|
|||||||
|
|
||||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||||
|
|
||||||
- Windows 10+ для `TgWsProxy_windows.exe`
|
- Windows 10+ x64 для `TgWsProxy_windows.exe`
|
||||||
|
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
|
||||||
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
||||||
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
||||||
- Intel macOS 10.15+
|
- Intel macOS 10.15+
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||||
|
|
||||||
- `TgWsProxy_windows.exe` (Windows 10+)
|
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||||
|
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||||
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||||
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ Tray-приложение хранит данные в:
|
|||||||
"log_max_mb": 5.0,
|
"log_max_mb": 5.0,
|
||||||
"check_updates": true,
|
"check_updates": true,
|
||||||
"cfproxy": true,
|
"cfproxy": true,
|
||||||
"cfproxy_priority": true,
|
|
||||||
"cfproxy_user_domain": "",
|
"cfproxy_user_domain": "",
|
||||||
|
"cfproxy_worker_domain": "",
|
||||||
"appearance": "auto"
|
"appearance": "auto"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
BIN
docs/images/preview-dark.png
Normal file
BIN
docs/images/preview-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/images/preview-white.png
Normal file
BIN
docs/images/preview-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/images/workflow.png
Normal file
BIN
docs/images/workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
69
macos.py
69
macos.py
@@ -24,7 +24,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pyperclip = None
|
pyperclip = None
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
from proxy.tg_ws_proxy import _run
|
from proxy.tg_ws_proxy import _run
|
||||||
|
|
||||||
from utils.tray_common import (
|
from utils.tray_common import (
|
||||||
@@ -32,6 +32,7 @@ from utils.tray_common import (
|
|||||||
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
|
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
|
||||||
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
|
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
|
||||||
)
|
)
|
||||||
|
from utils.diagnostics import diagnose_listen_error
|
||||||
|
|
||||||
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
|
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ _app: Optional[object] = None
|
|||||||
_config: dict = {}
|
_config: dict = {}
|
||||||
_exiting: bool = False
|
_exiting: bool = False
|
||||||
|
|
||||||
|
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
||||||
|
|
||||||
# osascript dialogs
|
# osascript dialogs
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +112,32 @@ def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> O
|
|||||||
return r.stdout.rstrip("\r\n")
|
return r.stdout.rstrip("\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_cfworker_domain(default: str) -> Optional[str]:
|
||||||
|
value = default
|
||||||
|
while True:
|
||||||
|
script = (
|
||||||
|
f'set d to display dialog "{_esc("Cloudflare Worker домены через запятую (например, name.account.workers.dev):")}" '
|
||||||
|
f'default answer "{_esc(value)}" '
|
||||||
|
f'with title "TG WS Proxy" '
|
||||||
|
f'buttons {{"Закрыть", "?", "OK"}} '
|
||||||
|
f'default button "OK" cancel button "Закрыть"\n'
|
||||||
|
f'return (button returned of d) & "\\n" & (text returned of d)'
|
||||||
|
)
|
||||||
|
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
out_lines = r.stdout.splitlines()
|
||||||
|
button = out_lines[0].strip() if out_lines else ""
|
||||||
|
value = out_lines[1].strip() if len(out_lines) > 1 else value
|
||||||
|
|
||||||
|
if button == "?":
|
||||||
|
webbrowser.open(_CFWORKER_HELP_URL)
|
||||||
|
continue
|
||||||
|
if button == "OK":
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
# menubar icon
|
# menubar icon
|
||||||
|
|
||||||
|
|
||||||
@@ -156,13 +185,9 @@ def _run_proxy_thread() -> None:
|
|||||||
loop.run_until_complete(_run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", exc)
|
log.error("Proxy thread crashed: %s", exc)
|
||||||
if "Address already in use" in str(exc):
|
msg, _ = diagnose_listen_error(exc)
|
||||||
_show_error(
|
if msg:
|
||||||
"Не удалось запустить прокси:\n"
|
_show_error(msg)
|
||||||
"Порт уже используется другим приложением.\n\n"
|
|
||||||
"Закройте приложение, использующее этот порт, "
|
|
||||||
"или измените порт в настройках прокси и перезапустите."
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
_async_stop = None
|
_async_stop = None
|
||||||
@@ -396,21 +421,25 @@ def _edit_config_dialog() -> None:
|
|||||||
if cfproxy is None:
|
if cfproxy is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
cfproxy_priority = True
|
|
||||||
if cfproxy:
|
|
||||||
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
|
|
||||||
if cfproxy_priority_result is None:
|
|
||||||
return
|
|
||||||
cfproxy_priority = cfproxy_priority_result
|
|
||||||
|
|
||||||
cfproxy_domain = _osascript_input(
|
cfproxy_domain = _osascript_input(
|
||||||
"Свой CF-домен (оставьте пустым для автоматического выбора):\n"
|
"Свои CF-домены через запятую (оставьте пустым для автоматического выбора):\n"
|
||||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
||||||
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")),
|
", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", []))
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
if cfproxy_domain is None:
|
if cfproxy_domain is None:
|
||||||
return
|
return
|
||||||
cfproxy_domain = cfproxy_domain.strip()
|
cfproxy_domains = coerce_domain_list(cfproxy_domain)
|
||||||
|
|
||||||
|
cfworker_domain = _ask_cfworker_domain(
|
||||||
|
", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", []))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
if cfworker_domain is None:
|
||||||
|
return
|
||||||
|
cfworker_domains = coerce_domain_list(cfworker_domain)
|
||||||
|
|
||||||
new_cfg = {
|
new_cfg = {
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -423,8 +452,8 @@ def _edit_config_dialog() -> None:
|
|||||||
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
||||||
"check_updates": cfg.get("check_updates", True),
|
"check_updates": cfg.get("check_updates", True),
|
||||||
"cfproxy": cfproxy,
|
"cfproxy": cfproxy,
|
||||||
"cfproxy_priority": cfproxy_priority,
|
"cfproxy_user_domain": cfproxy_domains,
|
||||||
"cfproxy_user_domain": cfproxy_domain,
|
"cfproxy_worker_domain": cfworker_domains,
|
||||||
}
|
}
|
||||||
save_config(new_cfg)
|
save_config(new_cfg)
|
||||||
log.info("Config saved: %s", new_cfg)
|
log.info("Config saved: %s", new_cfg)
|
||||||
|
|||||||
@@ -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, 6, 5, 0),
|
filevers=(1, 7, 3, 0),
|
||||||
prodvers=(1, 6, 5, 0),
|
prodvers=(1, 7, 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.6.6.0'),
|
StringStruct(u'FileVersion', u'1.7.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.6.6.0'),
|
StringStruct(u'ProductVersion', u'1.7.3.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from .config import parse_dc_ip_list, proxy_config
|
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
from .utils import get_link_host
|
from .utils import get_link_host, build_github_opener
|
||||||
|
|
||||||
__version__ = "1.6.6"
|
__version__ = "1.7.3"
|
||||||
|
|
||||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||||
130
proxy/_aes.py
Normal file
130
proxy/_aes.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
AES-CTR shim.
|
||||||
|
|
||||||
|
Prefers `cryptography` if available (desktop / Docker). Falls back to a
|
||||||
|
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
|
||||||
|
installing `cryptography` is painful (Entware on routers, embedded boxes
|
||||||
|
without a Rust toolchain). The public surface mimics the small subset of
|
||||||
|
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
|
||||||
|
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
|
||||||
|
Cipher, algorithms, modes,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
|
||||||
|
def _load_libcrypto():
|
||||||
|
name = ctypes.util.find_library("crypto")
|
||||||
|
candidates = []
|
||||||
|
if name:
|
||||||
|
candidates.append(name)
|
||||||
|
candidates += [
|
||||||
|
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
|
||||||
|
"libcrypto.so", "/opt/lib/libcrypto.so",
|
||||||
|
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
|
||||||
|
]
|
||||||
|
last_err = None
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
return ctypes.CDLL(c)
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
raise RuntimeError(
|
||||||
|
"libcrypto not found; install openssl-util or "
|
||||||
|
"`opkg install libopenssl`. Last error: %r" % last_err
|
||||||
|
)
|
||||||
|
|
||||||
|
_libcrypto = _load_libcrypto()
|
||||||
|
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
|
||||||
|
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
||||||
|
ctypes.c_char_p, ctypes.c_char_p,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
|
||||||
|
_libcrypto.EVP_EncryptUpdate.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
|
||||||
|
ctypes.c_char_p, ctypes.c_int,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
|
||||||
|
|
||||||
|
_EVP_BY_KEY = {
|
||||||
|
16: _libcrypto.EVP_aes_128_ctr,
|
||||||
|
24: _libcrypto.EVP_aes_192_ctr,
|
||||||
|
32: _libcrypto.EVP_aes_256_ctr,
|
||||||
|
}
|
||||||
|
|
||||||
|
class algorithms:
|
||||||
|
class AES:
|
||||||
|
__slots__ = ("key",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
if len(key) not in _EVP_BY_KEY:
|
||||||
|
raise ValueError("AES key must be 16/24/32 bytes")
|
||||||
|
self.key = bytes(key)
|
||||||
|
|
||||||
|
class modes:
|
||||||
|
class CTR:
|
||||||
|
__slots__ = ("iv",)
|
||||||
|
|
||||||
|
def __init__(self, iv: bytes):
|
||||||
|
if len(iv) != 16:
|
||||||
|
raise ValueError("CTR IV must be 16 bytes")
|
||||||
|
self.iv = bytes(iv)
|
||||||
|
|
||||||
|
class _CtrStream:
|
||||||
|
__slots__ = ("_ctx",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes, iv: bytes):
|
||||||
|
ctx = _libcrypto.EVP_CIPHER_CTX_new()
|
||||||
|
if not ctx:
|
||||||
|
raise RuntimeError("EVP_CIPHER_CTX_new failed")
|
||||||
|
self._ctx = ctx
|
||||||
|
evp = _EVP_BY_KEY[len(key)]()
|
||||||
|
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
raise RuntimeError("EVP_EncryptInit_ex failed")
|
||||||
|
|
||||||
|
def update(self, data: bytes) -> bytes:
|
||||||
|
if not data:
|
||||||
|
return b""
|
||||||
|
outlen = ctypes.c_int(0)
|
||||||
|
buf = ctypes.create_string_buffer(len(data) + 16)
|
||||||
|
if _libcrypto.EVP_EncryptUpdate(
|
||||||
|
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
|
||||||
|
) != 1:
|
||||||
|
raise RuntimeError("EVP_EncryptUpdate failed")
|
||||||
|
return buf.raw[:outlen.value]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
ctx = getattr(self, "_ctx", None)
|
||||||
|
if ctx:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
|
||||||
|
class Cipher:
|
||||||
|
__slots__ = ("_key", "_iv")
|
||||||
|
|
||||||
|
def __init__(self, algorithm, mode):
|
||||||
|
if not isinstance(algorithm, algorithms.AES):
|
||||||
|
raise TypeError("only AES is supported")
|
||||||
|
if not isinstance(mode, modes.CTR):
|
||||||
|
raise TypeError("only CTR mode is supported")
|
||||||
|
self._key = algorithm.key
|
||||||
|
self._iv = mode.iv
|
||||||
|
|
||||||
|
def encryptor(self) -> _CtrStream:
|
||||||
|
return _CtrStream(self._key, self._iv)
|
||||||
|
|
||||||
|
# CTR is symmetric — decryption == encryption with the same keystream.
|
||||||
|
decryptor = encryptor
|
||||||
155
proxy/bridge.py
155
proxy/bridge.py
@@ -1,29 +1,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import random
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from typing import List, Optional
|
||||||
from typing import Dict, List, Optional
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from .utils import *
|
from .utils import *
|
||||||
from .stats import stats
|
from .stats import stats
|
||||||
from .balancer import balancer
|
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 ._aes import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
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
|
ZERO_64 = b'\x00' * 64
|
||||||
DC_DEFAULT_IPS: Dict[int, str] = {
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoCtx:
|
class CryptoCtx:
|
||||||
@@ -63,19 +58,27 @@ class MsgSplitter:
|
|||||||
self._plain_buf.extend(self._dec.update(chunk))
|
self._plain_buf.extend(self._dec.update(chunk))
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
while self._cipher_buf:
|
offset = 0
|
||||||
packet_len = self._next_packet_len()
|
buf_len = len(self._cipher_buf)
|
||||||
|
# Walk the buffer with an offset instead of deleting each packet from
|
||||||
|
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
||||||
|
# so a chunk holding many small packets degrades to O(N^2); a single
|
||||||
|
# trailing del keeps splitting O(N).
|
||||||
|
while offset < buf_len:
|
||||||
|
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||||
if packet_len is None:
|
if packet_len is None:
|
||||||
break
|
break
|
||||||
if packet_len <= 0:
|
if packet_len <= 0:
|
||||||
parts.append(bytes(self._cipher_buf))
|
parts.append(bytes(self._cipher_buf[offset:]))
|
||||||
self._cipher_buf.clear()
|
offset = buf_len
|
||||||
self._plain_buf.clear()
|
|
||||||
self._disabled = True
|
self._disabled = True
|
||||||
break
|
break
|
||||||
parts.append(bytes(self._cipher_buf[:packet_len]))
|
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||||
del self._cipher_buf[:packet_len]
|
offset += packet_len
|
||||||
del self._plain_buf[:packet_len]
|
|
||||||
|
if offset:
|
||||||
|
del self._cipher_buf[:offset]
|
||||||
|
del self._plain_buf[:offset]
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
def flush(self) -> List[bytes]:
|
def flush(self) -> List[bytes]:
|
||||||
@@ -86,22 +89,23 @@ class MsgSplitter:
|
|||||||
self._plain_buf.clear()
|
self._plain_buf.clear()
|
||||||
return [tail]
|
return [tail]
|
||||||
|
|
||||||
def _next_packet_len(self) -> Optional[int]:
|
def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
if not self._plain_buf:
|
if avail <= 0:
|
||||||
return None
|
return None
|
||||||
if self._proto == PROTO_ABRIDGED_INT:
|
if self._proto == PROTO_ABRIDGED_INT:
|
||||||
return self._next_abridged_len()
|
return self._next_abridged_len(offset, avail)
|
||||||
if self._proto in (PROTO_INTERMEDIATE_INT,
|
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||||
PROTO_PADDED_INTERMEDIATE_INT):
|
PROTO_PADDED_INTERMEDIATE_INT):
|
||||||
return self._next_intermediate_len()
|
return self._next_intermediate_len(offset, avail)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _next_abridged_len(self) -> Optional[int]:
|
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
first = self._plain_buf[0]
|
first = self._plain_buf[offset]
|
||||||
if first in (0x7F, 0xFF):
|
if first in (0x7F, 0xFF):
|
||||||
if len(self._plain_buf) < 4:
|
if avail < 4:
|
||||||
return None
|
return None
|
||||||
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4
|
payload_len = int.from_bytes(
|
||||||
|
self._plain_buf[offset + 1:offset + 4], 'little') * 4
|
||||||
header_len = 4
|
header_len = 4
|
||||||
else:
|
else:
|
||||||
payload_len = (first & 0x7F) * 4
|
payload_len = (first & 0x7F) * 4
|
||||||
@@ -109,37 +113,47 @@ class MsgSplitter:
|
|||||||
if payload_len <= 0:
|
if payload_len <= 0:
|
||||||
return 0
|
return 0
|
||||||
packet_len = header_len + payload_len
|
packet_len = header_len + payload_len
|
||||||
if len(self._plain_buf) < packet_len:
|
if avail < packet_len:
|
||||||
return None
|
return None
|
||||||
return packet_len
|
return packet_len
|
||||||
|
|
||||||
def _next_intermediate_len(self) -> Optional[int]:
|
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
if len(self._plain_buf) < 4:
|
if avail < 4:
|
||||||
return None
|
return None
|
||||||
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF
|
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
|
||||||
if payload_len <= 0:
|
if payload_len <= 0:
|
||||||
return 0
|
return 0
|
||||||
packet_len = 4 + payload_len
|
packet_len = 4 + payload_len
|
||||||
if len(self._plain_buf) < packet_len:
|
if avail < packet_len:
|
||||||
return None
|
return None
|
||||||
return packet_len
|
return packet_len
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def do_fallback(reader, writer, relay_init, label,
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
dc: int, is_media: bool, media_tag: str,
|
dc: int, is_media: bool, media_tag: str,
|
||||||
ctx: CryptoCtx, splitter=None):
|
ctx: CryptoCtx, splitter=None):
|
||||||
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
||||||
use_cf = proxy_config.fallback_cfproxy
|
use_cf = proxy_config.fallback_cfproxy
|
||||||
cf_first = proxy_config.fallback_cfproxy_priority
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
methods: List[str] = ['tcp']
|
methods: List[str] = []
|
||||||
|
|
||||||
|
if worker_domains and fallback_dst:
|
||||||
|
methods.append('cf_worker')
|
||||||
if use_cf:
|
if use_cf:
|
||||||
methods.insert(0 if cf_first else 1, 'cf')
|
methods.append('cf')
|
||||||
|
if fallback_dst:
|
||||||
|
methods.append('tcp')
|
||||||
|
|
||||||
for method in methods:
|
for method in methods:
|
||||||
if method == 'cf':
|
if method == 'cf_worker' and fallback_dst:
|
||||||
|
ok = await _cfproxy_worker_fallback(
|
||||||
|
reader, writer, relay_init, label, ctx,
|
||||||
|
dc=dc, is_media=is_media, fallback_dst=fallback_dst,
|
||||||
|
splitter=splitter)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
elif method == 'cf':
|
||||||
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,
|
||||||
@@ -157,6 +171,50 @@ async def do_fallback(reader, writer, relay_init, label,
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||||
|
ctx: CryptoCtx,
|
||||||
|
dc: int, is_media: bool,
|
||||||
|
fallback_dst: str,
|
||||||
|
splitter=None):
|
||||||
|
media_tag = ' media' if is_media else ''
|
||||||
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
if not worker_domains:
|
||||||
|
return False
|
||||||
|
|
||||||
|
random.shuffle(worker_domains)
|
||||||
|
|
||||||
|
for worker_domain in worker_domains:
|
||||||
|
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
|
||||||
|
if ws:
|
||||||
|
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
|
||||||
|
label, dc, media_tag, fallback_dst)
|
||||||
|
else:
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
|
||||||
|
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||||
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
|
timeout=10.0, path=path)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||||
|
label, dc, media_tag, worker_domain, repr(exc))
|
||||||
|
continue
|
||||||
|
|
||||||
|
stats.connections_cfproxy += 1
|
||||||
|
await ws.send(relay_init)
|
||||||
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
|
dc=dc, is_media=is_media,
|
||||||
|
splitter=splitter)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
ctx: CryptoCtx,
|
ctx: CryptoCtx,
|
||||||
dc: int, is_media: bool,
|
dc: int, is_media: bool,
|
||||||
@@ -208,6 +266,26 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: Crypt
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ws_keepalive(ws, interval: float):
|
||||||
|
"""Send periodic WS PING frames to keep the upstream flow warm.
|
||||||
|
|
||||||
|
A non-positive interval disables keepalive. The loop exits on send
|
||||||
|
failure so a dead upstream is detected promptly instead of lingering
|
||||||
|
until the next client packet (see issue #646).
|
||||||
|
"""
|
||||||
|
if interval <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
interval = max(1.0, interval) # reasonable minimum
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
await ws.send_ping()
|
||||||
|
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||||
ctx: CryptoCtx,
|
ctx: CryptoCtx,
|
||||||
dc=None, is_media=False,
|
dc=None, is_media=False,
|
||||||
@@ -279,12 +357,15 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
|||||||
|
|
||||||
tasks = [asyncio.create_task(tcp_to_ws()),
|
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||||
asyncio.create_task(ws_to_tcp())]
|
asyncio.create_task(ws_to_tcp())]
|
||||||
|
keepalive = asyncio.create_task(
|
||||||
|
_ws_keepalive(ws, proxy_config.ws_keepalive_interval))
|
||||||
try:
|
try:
|
||||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
finally:
|
finally:
|
||||||
|
keepalive.cancel()
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
t.cancel()
|
t.cancel()
|
||||||
for t in tasks:
|
for t in (*tasks, keepalive):
|
||||||
try:
|
try:
|
||||||
await t
|
await t
|
||||||
except BaseException:
|
except BaseException:
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import threading
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request
|
||||||
|
|
||||||
from .balancer import balancer
|
from .balancer import balancer
|
||||||
|
from .utils import build_github_opener
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
@@ -28,7 +29,12 @@ _CFPROXY_ENC: List[str] = [
|
|||||||
'clngqrflngqin.com',
|
'clngqrflngqin.com',
|
||||||
'tjacxbqtj.com',
|
'tjacxbqtj.com',
|
||||||
'bxaxtxmrw.com',
|
'bxaxtxmrw.com',
|
||||||
'dmohrsgmohcrwb.com'
|
'dmohrsgmohcrwb.com',
|
||||||
|
'vwbmtmoi.com',
|
||||||
|
'khgrre.com',
|
||||||
|
'ulihssf.com',
|
||||||
|
'tmhqsdqmfpmk.com',
|
||||||
|
'xwuwoqbm.com'
|
||||||
]
|
]
|
||||||
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||||
|
|
||||||
@@ -57,20 +63,45 @@ class ProxyConfig:
|
|||||||
buffer_size: int = 256 * 1024
|
buffer_size: int = 256 * 1024
|
||||||
pool_size: int = 4
|
pool_size: int = 4
|
||||||
fallback_cfproxy: bool = True
|
fallback_cfproxy: bool = True
|
||||||
fallback_cfproxy_priority: bool = True
|
cfproxy_user_domains: List[str] = field(default_factory=list)
|
||||||
cfproxy_user_domain: str = ''
|
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||||
fake_tls_domain: str = ''
|
fake_tls_domain: str = ''
|
||||||
proxy_protocol: bool = False
|
proxy_protocol: bool = False
|
||||||
|
ws_keepalive_interval: float = 30.0
|
||||||
|
|
||||||
|
|
||||||
proxy_config = ProxyConfig()
|
proxy_config = ProxyConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_domain_list(value) -> List[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
items = value.replace(',', ' ').replace(';', ' ').split()
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
items: List[str] = []
|
||||||
|
for entry in value:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
seen = set()
|
||||||
|
result: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
key = item.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cfproxy_domain_list() -> List[str]:
|
def _fetch_cfproxy_domain_list() -> List[str]:
|
||||||
try:
|
try:
|
||||||
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||||
headers={'User-Agent': 'tg-ws-proxy'})
|
headers={'User-Agent': 'tg-ws-proxy'})
|
||||||
with urlopen(req, timeout=10) as resp:
|
with build_github_opener().open(req, timeout=10) as resp:
|
||||||
text = resp.read().decode('utf-8', errors='replace')
|
text = resp.read().decode('utf-8', errors='replace')
|
||||||
encoded = [
|
encoded = [
|
||||||
line.strip() for line in text.splitlines()
|
line.strip() for line in text.splitlines()
|
||||||
@@ -119,7 +150,7 @@ def _normalize_domain_pool(domains: List[str]) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def refresh_cfproxy_domains() -> None:
|
def refresh_cfproxy_domains() -> None:
|
||||||
if proxy_config.cfproxy_user_domain:
|
if proxy_config.cfproxy_user_domains:
|
||||||
return
|
return
|
||||||
|
|
||||||
fetched = _fetch_cfproxy_domain_list()
|
fetched = _fetch_cfproxy_domain_list()
|
||||||
@@ -174,4 +205,4 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
|||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
raise ValueError(f"Invalid --dc-ip {entry!r}")
|
raise ValueError(f"Invalid --dc-ip {entry!r}")
|
||||||
dc_redirects[dc_n] = ip_s
|
dc_redirects[dc_n] = ip_s
|
||||||
return dc_redirects
|
return dc_redirects
|
||||||
|
|||||||
214
proxy/pool.py
Normal file
214
proxy/pool.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from typing import Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
|
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||||
|
from .stats import stats
|
||||||
|
from .config import proxy_config
|
||||||
|
from .utils import ws_domains, DC_DEFAULT_IPS
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
class _WsPool:
|
||||||
|
WS_POOL_MAX_AGE = 120.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle: Dict[Tuple[int, bool], deque] = {}
|
||||||
|
self._refilling: Set[Tuple[int, bool]] = set()
|
||||||
|
|
||||||
|
async def get(self, dc: int, is_media: bool,
|
||||||
|
target_ip: str, domains: List[str]
|
||||||
|
) -> Optional[RawWebSocket]:
|
||||||
|
key = (dc, is_media)
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[key] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.pool_hits += 1
|
||||||
|
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
||||||
|
dc, 'm' if is_media else '', age, len(bucket))
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
stats.pool_misses += 1
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_refill(self, key, target_ip, domains):
|
||||||
|
if key in self._refilling:
|
||||||
|
return
|
||||||
|
self._refilling.add(key)
|
||||||
|
asyncio.create_task(self._refill(key, target_ip, domains))
|
||||||
|
|
||||||
|
async def _refill(self, key, target_ip, domains):
|
||||||
|
dc, is_media = key
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(key, deque())
|
||||||
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(
|
||||||
|
self._connect_one(target_ip, domains))
|
||||||
|
for _ in range(needed)]
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
ws = await t
|
||||||
|
if ws:
|
||||||
|
bucket.append((ws, time.monotonic()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log.debug("WS pool refilled DC%d%s: %d ready",
|
||||||
|
dc, 'm' if is_media else '', len(bucket))
|
||||||
|
finally:
|
||||||
|
self._refilling.discard(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
|
||||||
|
for domain in domains:
|
||||||
|
try:
|
||||||
|
return await RawWebSocket.connect(
|
||||||
|
target_ip, domain, timeout=8)
|
||||||
|
except WsHandshakeError as exc:
|
||||||
|
if exc.is_redirect:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _quiet_close(ws):
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
for dc, target_ip in proxy_config.dc_redirects.items():
|
||||||
|
if target_ip is None:
|
||||||
|
continue
|
||||||
|
for is_media in (False, True):
|
||||||
|
domains = ws_domains(dc, is_media)
|
||||||
|
self._schedule_refill((dc, is_media), target_ip, domains)
|
||||||
|
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._idle.clear()
|
||||||
|
self._refilling.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _CfWorkerPool:
|
||||||
|
WS_POOL_MAX_AGE = 120.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle: Dict[Tuple[int, str], deque] = {}
|
||||||
|
self._refilling: Set[Tuple[int, str]] = set()
|
||||||
|
|
||||||
|
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
|
||||||
|
now = time.monotonic()
|
||||||
|
key = (dc, worker_domain)
|
||||||
|
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[key] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.cf_pool_hits += 1
|
||||||
|
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
|
||||||
|
dc, age, len(bucket))
|
||||||
|
self._schedule_refill(key, fallback_dst)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
stats.cf_pool_misses += 1
|
||||||
|
self._schedule_refill(key, fallback_dst)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_refill(self, key, fallback_dst):
|
||||||
|
if key in self._refilling:
|
||||||
|
return
|
||||||
|
self._refilling.add(key)
|
||||||
|
asyncio.create_task(self._refill(key, fallback_dst))
|
||||||
|
|
||||||
|
async def _refill(self, key, fallback_dst):
|
||||||
|
dc, worker_domain = key
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(key, deque())
|
||||||
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(
|
||||||
|
self._connect_one(worker_domain, fallback_dst, dc))
|
||||||
|
for _ in range(needed)]
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
ws = await t
|
||||||
|
if ws:
|
||||||
|
bucket.append((ws, time.monotonic()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
|
dc, len(bucket))
|
||||||
|
finally:
|
||||||
|
self._refilling.discard(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
try:
|
||||||
|
return await RawWebSocket.connect(
|
||||||
|
worker_domain, worker_domain, timeout=8, path=path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _quiet_close(ws):
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
cf_fallbacks = {
|
||||||
|
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
|
||||||
|
if dc not in proxy_config.dc_redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
||||||
|
return
|
||||||
|
|
||||||
|
for worker_domain in proxy_config.cfproxy_worker_domains:
|
||||||
|
for dc, fallback_dst in cf_fallbacks.items():
|
||||||
|
self._schedule_refill((dc, worker_domain), fallback_dst)
|
||||||
|
|
||||||
|
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._idle.clear()
|
||||||
|
self._refilling.clear()
|
||||||
|
|
||||||
|
|
||||||
|
ws_pool = _WsPool()
|
||||||
|
cf_worker_pool = _CfWorkerPool()
|
||||||
@@ -78,7 +78,8 @@ class RawWebSocket:
|
|||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def connect(host: str, domain: str, timeout: float = 10.0) -> 'RawWebSocket':
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
|
path: str = '/apiws') -> 'RawWebSocket':
|
||||||
reader, writer = await asyncio.wait_for(
|
reader, writer = await asyncio.wait_for(
|
||||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||||
server_hostname=domain),
|
server_hostname=domain),
|
||||||
@@ -89,7 +90,7 @@ class RawWebSocket:
|
|||||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
|
||||||
req = (
|
req = (
|
||||||
f'GET /apiws HTTP/1.1\r\n'
|
f'GET {path} HTTP/1.1\r\n'
|
||||||
f'Host: {domain}\r\n'
|
f'Host: {domain}\r\n'
|
||||||
f'Upgrade: websocket\r\n'
|
f'Upgrade: websocket\r\n'
|
||||||
f'Connection: Upgrade\r\n'
|
f'Connection: Upgrade\r\n'
|
||||||
@@ -153,6 +154,13 @@ class RawWebSocket:
|
|||||||
self._build_frame(self.OP_BINARY, part, mask=True))
|
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||||
await self.writer.drain()
|
await self.writer.drain()
|
||||||
|
|
||||||
|
async def send_ping(self, payload: bytes = b''):
|
||||||
|
if self._closed:
|
||||||
|
raise ConnectionError("WebSocket closed")
|
||||||
|
frame = self._build_frame(self.OP_PING, payload, mask=True)
|
||||||
|
self.writer.write(frame)
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
async def recv(self) -> Optional[bytes]:
|
async def recv(self) -> Optional[bytes]:
|
||||||
while not self._closed:
|
while not self._closed:
|
||||||
opcode, payload = await self._read_frame()
|
opcode, payload = await self._read_frame()
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ class _Stats:
|
|||||||
self.bytes_down = 0
|
self.bytes_down = 0
|
||||||
self.pool_hits = 0
|
self.pool_hits = 0
|
||||||
self.pool_misses = 0
|
self.pool_misses = 0
|
||||||
|
self.cf_pool_hits = 0
|
||||||
|
self.cf_pool_misses = 0
|
||||||
|
|
||||||
def summary(self) -> str:
|
def summary(self) -> str:
|
||||||
pool_total = self.pool_hits + self.pool_misses
|
pool_total = self.pool_hits + self.pool_misses
|
||||||
pool_s = (f"{self.pool_hits}/{pool_total}"
|
pool_s = (f"{self.pool_hits}/{pool_total}"
|
||||||
if pool_total else "n/a")
|
if pool_total else "n/a")
|
||||||
|
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
|
||||||
|
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
|
||||||
|
if cf_pool_total else "n/a")
|
||||||
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} "
|
||||||
@@ -28,6 +33,7 @@ class _Stats:
|
|||||||
f"masked={self.connections_masked} "
|
f"masked={self.connections_masked} "
|
||||||
f"err={self.ws_errors} "
|
f"err={self.ws_errors} "
|
||||||
f"pool={pool_s} "
|
f"pool={pool_s} "
|
||||||
|
f"cf_pool={cf_pool_s} "
|
||||||
f"up={human_bytes(self.bytes_up)} "
|
f"up={human_bytes(self.bytes_up)} "
|
||||||
f"down={human_bytes(self.bytes_down)}")
|
f"down={human_bytes(self.bytes_down)}")
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import logging
|
|||||||
import logging.handlers
|
import logging.handlers
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
|
|
||||||
from collections import deque
|
from typing import Dict, Optional, Set, Tuple
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
|
|
||||||
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
||||||
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -24,11 +22,13 @@ 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
|
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
|
||||||
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
|
from .raw_websocket import RawWebSocket, WsHandshakeError, 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, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
|
||||||
from .balancer import balancer
|
from .balancer import balancer
|
||||||
|
from .pool import ws_pool, cf_worker_pool
|
||||||
|
from ._aes import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
@@ -100,112 +100,8 @@ def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes:
|
|||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
def _ws_domains(dc: int, is_media) -> List[str]:
|
|
||||||
if dc == 203:
|
|
||||||
dc = 2
|
|
||||||
if is_media is None or is_media:
|
|
||||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
|
||||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
|
||||||
|
|
||||||
|
|
||||||
class _WsPool:
|
|
||||||
WS_POOL_MAX_AGE = 120.0
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._idle: Dict[Tuple[int, bool], deque] = {}
|
|
||||||
self._refilling: Set[Tuple[int, bool]] = set()
|
|
||||||
|
|
||||||
async def get(self, dc: int, is_media: bool,
|
|
||||||
target_ip: str, domains: List[str]
|
|
||||||
) -> Optional[RawWebSocket]:
|
|
||||||
key = (dc, is_media)
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
bucket = self._idle.get(key)
|
|
||||||
if bucket is None:
|
|
||||||
bucket = deque()
|
|
||||||
self._idle[key] = bucket
|
|
||||||
while bucket:
|
|
||||||
ws, created = bucket.popleft()
|
|
||||||
age = now - created
|
|
||||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
|
||||||
or ws.writer.transport.is_closing()):
|
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
|
||||||
continue
|
|
||||||
stats.pool_hits += 1
|
|
||||||
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
|
||||||
dc, 'm' if is_media else '', age, len(bucket))
|
|
||||||
self._schedule_refill(key, target_ip, domains)
|
|
||||||
return ws
|
|
||||||
|
|
||||||
stats.pool_misses += 1
|
|
||||||
self._schedule_refill(key, target_ip, domains)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _schedule_refill(self, key, target_ip, domains):
|
|
||||||
if key in self._refilling:
|
|
||||||
return
|
|
||||||
self._refilling.add(key)
|
|
||||||
asyncio.create_task(self._refill(key, target_ip, domains))
|
|
||||||
|
|
||||||
async def _refill(self, key, target_ip, domains):
|
|
||||||
dc, is_media = key
|
|
||||||
try:
|
|
||||||
bucket = self._idle.setdefault(key, deque())
|
|
||||||
needed = proxy_config.pool_size - len(bucket)
|
|
||||||
if needed <= 0:
|
|
||||||
return
|
|
||||||
tasks = [asyncio.create_task(
|
|
||||||
self._connect_one(target_ip, domains))
|
|
||||||
for _ in range(needed)]
|
|
||||||
for t in tasks:
|
|
||||||
try:
|
|
||||||
ws = await t
|
|
||||||
if ws:
|
|
||||||
bucket.append((ws, time.monotonic()))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.debug("WS pool refilled DC%d%s: %d ready",
|
|
||||||
dc, 'm' if is_media else '', len(bucket))
|
|
||||||
finally:
|
|
||||||
self._refilling.discard(key)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
|
|
||||||
for domain in domains:
|
|
||||||
try:
|
|
||||||
return await RawWebSocket.connect(
|
|
||||||
target_ip, domain, timeout=8)
|
|
||||||
except WsHandshakeError as exc:
|
|
||||||
if exc.is_redirect:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _quiet_close(ws):
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def warmup(self, dc_redirects: Dict[int, str]):
|
|
||||||
for dc, target_ip in dc_redirects.items():
|
|
||||||
if target_ip is None:
|
|
||||||
continue
|
|
||||||
for is_media in (False, True):
|
|
||||||
domains = _ws_domains(dc, is_media)
|
|
||||||
self._schedule_refill((dc, is_media), target_ip, domains)
|
|
||||||
log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self._idle.clear()
|
|
||||||
self._refilling.clear()
|
|
||||||
|
|
||||||
_ws_pool = _WsPool()
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_client_init(reader, writer, secret, label, masking):
|
async def _read_client_init(reader, writer, secret, label, masking):
|
||||||
if proxy_config.proxy_protocol:
|
if proxy_config.proxy_protocol:
|
||||||
@@ -420,13 +316,13 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
fail_until = dc_fail_until.get(dc_key, 0)
|
fail_until = dc_fail_until.get(dc_key, 0)
|
||||||
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
|
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
|
||||||
|
|
||||||
domains = _ws_domains(dc, is_media)
|
domains = ws_domains(dc, is_media)
|
||||||
target = proxy_config.dc_redirects[dc]
|
target = proxy_config.dc_redirects[dc]
|
||||||
ws = None
|
ws = None
|
||||||
ws_failed_redirect = False
|
ws_failed_redirect = False
|
||||||
all_redirects = True
|
all_redirects = True
|
||||||
|
|
||||||
ws = await _ws_pool.get(dc, is_media, target, domains)
|
ws = await ws_pool.get(dc, is_media, target, domains)
|
||||||
if ws:
|
if ws:
|
||||||
log.info("[%s] DC%d%s -> pool hit via %s",
|
log.info("[%s] DC%d%s -> pool hit via %s",
|
||||||
label, dc, media_tag, target)
|
label, dc, media_tag, target)
|
||||||
@@ -536,15 +432,16 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
global _server_instance, _server_stop_event
|
global _server_instance, _server_stop_event
|
||||||
_server_stop_event = stop_event
|
_server_stop_event = stop_event
|
||||||
|
|
||||||
_ws_pool.reset()
|
ws_pool.reset()
|
||||||
|
cf_worker_pool.reset()
|
||||||
ws_blacklist.clear()
|
ws_blacklist.clear()
|
||||||
dc_fail_until.clear()
|
dc_fail_until.clear()
|
||||||
_client_tasks.clear()
|
_client_tasks.clear()
|
||||||
|
|
||||||
if proxy_config.fallback_cfproxy:
|
if proxy_config.fallback_cfproxy:
|
||||||
user = proxy_config.cfproxy_user_domain
|
user = proxy_config.cfproxy_user_domains
|
||||||
if user:
|
if user:
|
||||||
balancer.update_domains_list([user])
|
balancer.update_domains_list(user)
|
||||||
else:
|
else:
|
||||||
start_cfproxy_domain_refresh()
|
start_cfproxy_domain_refresh()
|
||||||
|
|
||||||
@@ -587,9 +484,11 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
ip = proxy_config.dc_redirects.get(dc)
|
ip = proxy_config.dc_redirects.get(dc)
|
||||||
log.info(" DC%d: %s", dc, ip)
|
log.info(" DC%d: %s", dc, ip)
|
||||||
if proxy_config.fallback_cfproxy:
|
if proxy_config.fallback_cfproxy:
|
||||||
prio = 'CF first' if proxy_config.fallback_cfproxy_priority else 'TCP first'
|
user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
|
||||||
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto"
|
log.info(" CF proxy: enabled (%s)", user_domain)
|
||||||
log.info(" CF proxy: enabled (%s | %s)", prio, user_domain)
|
if proxy_config.cfproxy_worker_domains:
|
||||||
|
log.info(" CF worker: enabled (%s)",
|
||||||
|
", ".join(proxy_config.cfproxy_worker_domains))
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info(" Connect:")
|
log.info(" Connect:")
|
||||||
if ftls:
|
if ftls:
|
||||||
@@ -609,7 +508,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
|
|
||||||
log_stats_task = asyncio.create_task(log_stats())
|
log_stats_task = asyncio.create_task(log_stats())
|
||||||
|
|
||||||
await _ws_pool.warmup(proxy_config.dc_redirects)
|
await ws_pool.warmup()
|
||||||
|
await cf_worker_pool.warmup()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with server:
|
async with server:
|
||||||
@@ -651,16 +551,6 @@ def run_proxy(stop_event: Optional[asyncio.Event] = None):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
def _parse_bool(value: str) -> bool:
|
|
||||||
lowered = value.strip().lower()
|
|
||||||
if lowered == 'true':
|
|
||||||
return True
|
|
||||||
if lowered == 'false':
|
|
||||||
return False
|
|
||||||
raise argparse.ArgumentTypeError(
|
|
||||||
"Expected boolean value: true or false",
|
|
||||||
)
|
|
||||||
|
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description='Telegram MTProto WebSocket Bridge Proxy')
|
description='Telegram MTProto WebSocket Bridge Proxy')
|
||||||
ap.add_argument('--port', type=int, default=1443,
|
ap.add_argument('--port', type=int, default=1443,
|
||||||
@@ -678,19 +568,24 @@ def main():
|
|||||||
help='Log to file with rotation (default: stderr only)')
|
help='Log to file with rotation (default: stderr only)')
|
||||||
ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB',
|
ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB',
|
||||||
help='Max log file size in MB before rotation (default 5)')
|
help='Max log file size in MB before rotation (default 5)')
|
||||||
ap.add_argument('--log-backups', type=int, default=0, metavar='N',
|
ap.add_argument('--log-backups', type=int, default=1, metavar='N',
|
||||||
help='Number of rotated log files to keep (default 0)')
|
help='Number of rotated log files to keep (min 1; '
|
||||||
|
'rotation needs at least one backup to bound size)')
|
||||||
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, min 0)')
|
||||||
ap.add_argument('--cfproxy-domain', type=str, default='',
|
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 WS fallback '
|
||||||
|
'(repeatable for multiple domains)')
|
||||||
|
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
|
||||||
|
metavar='DOMAIN',
|
||||||
|
help='Cloudflare Worker domain for WS fallback '
|
||||||
|
'(tried before other fallback methods, '
|
||||||
|
'repeatable for multiple domains)')
|
||||||
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('--cfproxy-priority', type=_parse_bool, default=True,
|
|
||||||
help='Try cfproxy before tcp fallback (default: true)')
|
|
||||||
ap.add_argument('--fake-tls-domain', type=str, default='',
|
ap.add_argument('--fake-tls-domain', type=str, default='',
|
||||||
metavar='DOMAIN',
|
metavar='DOMAIN',
|
||||||
help='Enable Fake TLS (ee-secret) masking with the given '
|
help='Enable Fake TLS (ee-secret) masking with the given '
|
||||||
@@ -698,6 +593,10 @@ def main():
|
|||||||
ap.add_argument('--proxy-protocol', action='store_true',
|
ap.add_argument('--proxy-protocol', action='store_true',
|
||||||
help='Accept PROXY protocol v1 header '
|
help='Accept PROXY protocol v1 header '
|
||||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||||
|
ap.add_argument('--ws-keepalive', type=float, default=30.0, metavar='SEC',
|
||||||
|
help='Seconds between WebSocket keepalive PINGs to the '
|
||||||
|
'upstream (default 30, 0 to disable). Keeps idle '
|
||||||
|
'sessions alive through NAT/firewall timeouts.')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not args.dc_ip:
|
if not args.dc_ip:
|
||||||
@@ -730,10 +629,11 @@ def main():
|
|||||||
proxy_config.buffer_size = max(4, args.buf_kb) * 1024
|
proxy_config.buffer_size = max(4, args.buf_kb) * 1024
|
||||||
proxy_config.pool_size = max(0, args.pool_size)
|
proxy_config.pool_size = max(0, args.pool_size)
|
||||||
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
||||||
proxy_config.fallback_cfproxy_priority = args.cfproxy_priority
|
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
|
||||||
proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip()
|
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
||||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||||
proxy_config.proxy_protocol = args.proxy_protocol
|
proxy_config.proxy_protocol = args.proxy_protocol
|
||||||
|
proxy_config.ws_keepalive_interval = max(0, args.ws_keepalive)
|
||||||
|
|
||||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||||
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
||||||
@@ -746,11 +646,11 @@ def main():
|
|||||||
root.addHandler(console)
|
root.addHandler(console)
|
||||||
|
|
||||||
if args.log_file:
|
if args.log_file:
|
||||||
fh = logging.handlers.RotatingFileHandler(
|
from utils.logging_setup import build_log_handler
|
||||||
|
fh = build_log_handler(
|
||||||
args.log_file,
|
args.log_file,
|
||||||
maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)),
|
log_max_mb=args.log_max_mb,
|
||||||
backupCount=max(0, args.log_backups),
|
backups=args.log_backups,
|
||||||
encoding='utf-8',
|
|
||||||
)
|
)
|
||||||
fh.setFormatter(log_fmt)
|
fh.setFormatter(log_fmt)
|
||||||
root.addHandler(fh)
|
root.addHandler(fh)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import socket as _socket
|
import socket as _socket
|
||||||
|
import urllib.request
|
||||||
|
import http.client
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List
|
||||||
|
from urllib.request import Request
|
||||||
|
|
||||||
|
|
||||||
ZERO_64 = b'\x00' * 64
|
ZERO_64 = b'\x00' * 64
|
||||||
@@ -26,6 +29,28 @@ RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
|||||||
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
||||||
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||||
|
|
||||||
|
_GITHUB_IPS: Dict[str, str] = {
|
||||||
|
"release-assets.githubusercontent.com": "185.199.109.133",
|
||||||
|
"raw.githubusercontent.com": "185.199.109.133",
|
||||||
|
}
|
||||||
|
|
||||||
|
DC_DEFAULT_IPS: Dict[int, str] = {
|
||||||
|
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 ws_domains(dc: int, is_media) -> List[str]:
|
||||||
|
if dc == 203:
|
||||||
|
dc = 2
|
||||||
|
if is_media is None or is_media:
|
||||||
|
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||||
|
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
||||||
|
|
||||||
|
|
||||||
def human_bytes(n: int) -> str:
|
def human_bytes(n: int) -> str:
|
||||||
for unit in ('B', 'KB', 'MB', 'GB'):
|
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||||
@@ -45,4 +70,35 @@ def get_link_host(host: str) -> Optional[str]:
|
|||||||
link_host = '127.0.0.1'
|
link_host = '127.0.0.1'
|
||||||
return link_host
|
return link_host
|
||||||
else:
|
else:
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||||
|
def https_open(self, req: Request):
|
||||||
|
host = req.host.split(":")[0]
|
||||||
|
ip = _GITHUB_IPS.get(host)
|
||||||
|
if not ip:
|
||||||
|
return super().https_open(req)
|
||||||
|
pinned = ip
|
||||||
|
|
||||||
|
class _Conn(http.client.HTTPSConnection):
|
||||||
|
def connect(self):
|
||||||
|
self.sock = _socket.create_connection(
|
||||||
|
(pinned, self.port or 443),
|
||||||
|
self.timeout,
|
||||||
|
self.source_address,
|
||||||
|
)
|
||||||
|
if self._tunnel_host:
|
||||||
|
self._tunnel()
|
||||||
|
self.sock = self._context.wrap_socket(
|
||||||
|
self.sock, server_hostname=self._tunnel_host or self.host
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.do_open(_Conn, req)
|
||||||
|
except Exception:
|
||||||
|
return super().https_open(req)
|
||||||
|
|
||||||
|
|
||||||
|
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||||
|
return urllib.request.build_opener(_PinnedHTTPSHandler())
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list
|
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
|
||||||
from proxy.balancer import balancer
|
from proxy.balancer import balancer
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ from ui.ctk_theme import (
|
|||||||
)
|
)
|
||||||
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
_TIP_HOST = (
|
_TIP_HOST = (
|
||||||
"Адрес, на котором прокси принимает подключения.\n"
|
"Адрес, на котором прокси принимает подключения.\n"
|
||||||
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
||||||
@@ -55,24 +58,36 @@ _TIP_CHECK_UPDATES = "При запуске проверять наличие о
|
|||||||
_TIP_CFPROXY = (
|
_TIP_CFPROXY = (
|
||||||
"Использовать Cloudflare прокси для недоступных датацентров"
|
"Использовать Cloudflare прокси для недоступных датацентров"
|
||||||
)
|
)
|
||||||
_TIP_CFPROXY_PRIORITY = (
|
|
||||||
"Пробовать CF-прокси раньше прямого TCP-подключения"
|
|
||||||
)
|
|
||||||
_TIP_CFPROXY_DOMAIN = (
|
_TIP_CFPROXY_DOMAIN = (
|
||||||
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n"
|
"Ваши собственные домены, проксируемые через Cloudflare, для 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_SAVE = "Сохранить настройки"
|
_TIP_SAVE = "Сохранить настройки"
|
||||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
||||||
|
|
||||||
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
||||||
|
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
||||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
_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_cfproxy_connectivity_test(domain: str) -> dict:
|
def _run_connectivity_test(cases: list) -> dict:
|
||||||
import base64
|
import base64
|
||||||
import ssl
|
import ssl
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
@@ -81,15 +96,14 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
|||||||
ctx.check_hostname = False
|
ctx.check_hostname = False
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
results = {}
|
results = {}
|
||||||
for dc in _CFPROXY_TEST_DCS:
|
for dc, connect_host, sni_host, req_host, path in cases:
|
||||||
host = f"kws{dc}.{domain}"
|
|
||||||
try:
|
try:
|
||||||
with _socket.create_connection((host, 443), timeout=5) as raw:
|
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
|
||||||
with ctx.wrap_socket(raw, server_hostname=host) as ssock:
|
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
|
||||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||||
req = (
|
req = (
|
||||||
f"GET /apiws HTTP/1.1\r\n"
|
f"GET {path} HTTP/1.1\r\n"
|
||||||
f"Host: {host}\r\n"
|
f"Host: {req_host}\r\n"
|
||||||
f"Upgrade: websocket\r\n"
|
f"Upgrade: websocket\r\n"
|
||||||
f"Connection: Upgrade\r\n"
|
f"Connection: Upgrade\r\n"
|
||||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||||
@@ -120,6 +134,31 @@ def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
||||||
|
cases = []
|
||||||
|
for dc in _CFPROXY_TEST_DCS:
|
||||||
|
host = f"kws{dc}.{domain}"
|
||||||
|
cases.append((dc, host, host, host, "/apiws"))
|
||||||
|
return _run_connectivity_test(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfworker_connectivity_test(domain: str) -> dict:
|
||||||
|
cases = []
|
||||||
|
for dc in _CFPROXY_TEST_DCS:
|
||||||
|
dst = _CFWORKER_TEST_DST[dc]
|
||||||
|
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
||||||
|
cases.append((dc, domain, domain, domain, path))
|
||||||
|
return _run_connectivity_test(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfproxy_multi_test(domains: list) -> dict:
|
||||||
|
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfworker_multi_test(domains: list) -> dict:
|
||||||
|
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
||||||
merged: dict = {}
|
merged: dict = {}
|
||||||
best_domain = None
|
best_domain = None
|
||||||
@@ -136,27 +175,39 @@ def _run_cfproxy_auto_test(domains: list) -> tuple:
|
|||||||
return best_domain, merged
|
return best_domain, merged
|
||||||
|
|
||||||
|
|
||||||
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
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
|
import tkinter as _tk
|
||||||
from tkinter import messagebox as _mb
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
ok = [dc for dc, v in results.items() if v is True]
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
if auto_mode:
|
||||||
if len(ok) == len(_CFPROXY_TEST_DCS):
|
if domain:
|
||||||
title = "CF-прокси: всё работает"
|
title = f"{title_base}: доступен"
|
||||||
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
|
||||||
elif not ok:
|
else:
|
||||||
title = "CF-прокси: недоступен"
|
title = f"{title_base}: недоступен"
|
||||||
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
msg = unavailable_message
|
||||||
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
|
||||||
else:
|
else:
|
||||||
title = "CF-прокси: частично работает"
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
msg = (
|
if len(ok) == len(_CFPROXY_TEST_DCS):
|
||||||
f"Домен: {domain}\n\n"
|
title = f"{title_base}: всё работает"
|
||||||
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n"
|
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
||||||
f"\u2717 Недоступны:\n"
|
elif not ok:
|
||||||
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
title = f"{title_base}: недоступен"
|
||||||
)
|
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
||||||
|
msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
||||||
|
else:
|
||||||
|
title = f"{title_base}: частично работает"
|
||||||
|
msg = (
|
||||||
|
f"Домен: {domain}\n\n"
|
||||||
|
f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n"
|
||||||
|
f"\u2717 Недоступны:\n"
|
||||||
|
+ "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
|
||||||
|
)
|
||||||
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
try:
|
try:
|
||||||
@@ -167,18 +218,42 @@ def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
|||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None:
|
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
||||||
|
label_prefix: str = 'DC') -> None:
|
||||||
import tkinter as _tk
|
import tkinter as _tk
|
||||||
from tkinter import messagebox as _mb
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
if ok_domain is not None:
|
total = len(_CFPROXY_TEST_DCS)
|
||||||
title = "CF-прокси: доступен"
|
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]
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
msg = f"\u2713 CF-прокси работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
|
if len(ok) == total:
|
||||||
|
any_ok = True
|
||||||
|
blocks.append(f"\u2713 {domain}: все {total} серверов доступны")
|
||||||
|
elif not ok:
|
||||||
|
all_ok = False
|
||||||
|
blocks.append(f"\u2717 {domain}: недоступен")
|
||||||
|
else:
|
||||||
|
all_ok = False
|
||||||
|
any_ok = True
|
||||||
|
blocks.append(
|
||||||
|
f"~ {domain}: работают "
|
||||||
|
f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; "
|
||||||
|
f"недоступны "
|
||||||
|
f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_ok:
|
||||||
|
title = f"{title_base}: всё работает"
|
||||||
|
elif any_ok:
|
||||||
|
title = f"{title_base}: частично работает"
|
||||||
else:
|
else:
|
||||||
title = "CF-прокси: недоступен"
|
title = f"{title_base}: недоступен"
|
||||||
msg = "\u2717 Ни один из автоматических CF-доменов не отвечает.\n"
|
msg = "\n\n".join(blocks)
|
||||||
msg += "Возможно, блокировка или проблемы с сетью."
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
try:
|
try:
|
||||||
@@ -296,8 +371,8 @@ class TrayConfigFormWidgets:
|
|||||||
autostart_var: Optional[Any]
|
autostart_var: Optional[Any]
|
||||||
check_updates_var: Optional[Any]
|
check_updates_var: Optional[Any]
|
||||||
cfproxy_var: Optional[Any] = None
|
cfproxy_var: Optional[Any] = None
|
||||||
cfproxy_priority_var: Optional[Any] = None
|
|
||||||
cfproxy_user_domain_var: Optional[Any] = None
|
cfproxy_user_domain_var: Optional[Any] = None
|
||||||
|
cfproxy_worker_domain_var: Optional[Any] = None
|
||||||
appearance_var: Optional[Any] = None
|
appearance_var: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -428,27 +503,28 @@ def install_tray_config_form(
|
|||||||
cf_cb.pack(side="left", padx=(0, 16))
|
cf_cb.pack(side="left", padx=(0, 16))
|
||||||
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
||||||
|
|
||||||
cfproxy_priority_var = ctk.BooleanVar(
|
|
||||||
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
|
|
||||||
)
|
|
||||||
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет", cfproxy_priority_var)
|
|
||||||
cf_prio_cb.pack(side="left")
|
|
||||||
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
|
|
||||||
|
|
||||||
_cf_test_btn = [None]
|
_cf_test_btn = [None]
|
||||||
|
|
||||||
def _on_cf_test():
|
def _on_cf_test():
|
||||||
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else ""
|
user_domains = (
|
||||||
|
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||||
|
if cf_custom_cb_var.get() else []
|
||||||
|
)
|
||||||
btn = _cf_test_btn[0]
|
btn = _cf_test_btn[0]
|
||||||
if btn:
|
if btn:
|
||||||
btn.configure(text="...", state="disabled")
|
btn.configure(text="...", state="disabled")
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
if user_domain:
|
if user_domains:
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
res = _run_cfproxy_connectivity_test(user_domain)
|
per = _run_cfproxy_multi_test(user_domains)
|
||||||
if btn:
|
if btn:
|
||||||
btn.after(0, lambda: _cfproxy_show_test_results(user_domain, res))
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_multi_connectivity_results(
|
||||||
|
"CF-прокси", per, label_prefix='kws',
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CF proxy test failed: %s", exc)
|
log.error("CF proxy test failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
@@ -460,7 +536,17 @@ def install_tray_config_form(
|
|||||||
try:
|
try:
|
||||||
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
|
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
|
||||||
if btn:
|
if btn:
|
||||||
btn.after(0, lambda: _cfproxy_show_auto_test_results(ok_domain, res))
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_connectivity_results(
|
||||||
|
"CF-прокси", res,
|
||||||
|
domain=ok_domain or '',
|
||||||
|
auto_mode=True,
|
||||||
|
unavailable_message=(
|
||||||
|
"\u2717 Ни один из автоматических CF-доменов не отвечает."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CF proxy auto-test failed: %s", exc)
|
log.error("CF proxy auto-test failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
@@ -481,8 +567,10 @@ def install_tray_config_form(
|
|||||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_custom_row.pack(fill="x")
|
cf_custom_row.pack(fill="x")
|
||||||
|
|
||||||
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
saved_user_domains = coerce_domain_list(
|
||||||
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain))
|
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
|
)
|
||||||
|
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
|
||||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
||||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||||
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
|
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB)
|
||||||
@@ -495,7 +583,7 @@ def install_tray_config_form(
|
|||||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain)
|
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
||||||
cf_domain_entry = _entry(
|
cf_domain_entry = _entry(
|
||||||
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||||
height=32, radius=8,
|
height=32, radius=8,
|
||||||
@@ -512,6 +600,82 @@ def install_tray_config_form(
|
|||||||
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||||
_sync_domain_entry()
|
_sync_domain_entry()
|
||||||
|
|
||||||
|
cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker")
|
||||||
|
|
||||||
|
cf_worker_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, "Cloudflare Worker домены (через запятую)", size=11)
|
||||||
|
cf_worker_lbl.pack(anchor="w", pady=(0, 2))
|
||||||
|
|
||||||
|
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||||
|
cf_worker_input.pack(fill="x")
|
||||||
|
|
||||||
|
cfproxy_worker_domain_var = ctk.StringVar(
|
||||||
|
value=", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
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], _TIP_CFWORKER_DOMAIN)
|
||||||
|
|
||||||
|
_cfworker_test_btn = [None]
|
||||||
|
|
||||||
|
def _sync_cfworker_test_button(*_):
|
||||||
|
btn = _cfworker_test_btn[0]
|
||||||
|
if btn is None:
|
||||||
|
return
|
||||||
|
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||||
|
btn.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
|
def _on_cfworker_test():
|
||||||
|
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||||
|
btn = _cfworker_test_btn[0]
|
||||||
|
if not domains or btn is None:
|
||||||
|
return
|
||||||
|
btn.configure(text="...", state="disabled")
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
per = _run_cfworker_multi_test(domains)
|
||||||
|
btn.after(
|
||||||
|
0,
|
||||||
|
lambda: _show_multi_connectivity_results(
|
||||||
|
"CF Worker", per, label_prefix='DC',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("CF worker test failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
btn.after(0, lambda: btn.configure(text="Тест"))
|
||||||
|
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(_CFWORKER_HELP_URL),
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
_cfworker_test_widget = ctk.CTkButton(
|
||||||
|
cf_worker_input, text="Тест", width=56, 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", border_width=1, border_color=theme.field_border,
|
||||||
|
command=_on_cfworker_test,
|
||||||
|
)
|
||||||
|
_cfworker_test_widget.pack(side="right", padx=(0, 6))
|
||||||
|
_cfworker_test_btn[0] = _cfworker_test_widget
|
||||||
|
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||||
|
_sync_cfworker_test_button()
|
||||||
|
|
||||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
||||||
|
|
||||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
||||||
@@ -601,8 +765,8 @@ def install_tray_config_form(
|
|||||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||||
cfproxy_var=cfproxy_var,
|
cfproxy_var=cfproxy_var,
|
||||||
cfproxy_priority_var=cfproxy_priority_var,
|
|
||||||
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||||
|
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||||
appearance_var=appearance_var,
|
appearance_var=appearance_var,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -682,10 +846,10 @@ def validate_config_form(
|
|||||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||||
if widgets.cfproxy_var is not None:
|
if widgets.cfproxy_var is not None:
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
if widgets.cfproxy_priority_var is not None:
|
|
||||||
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
|
|
||||||
if widgets.cfproxy_user_domain_var is not None:
|
if widgets.cfproxy_user_domain_var is not None:
|
||||||
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip()
|
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_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:
|
if widgets.appearance_var is not None:
|
||||||
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
|
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
|
||||||
return new_cfg
|
return new_cfg
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"cfproxy": True,
|
"cfproxy": True,
|
||||||
"cfproxy_priority": True,
|
"cfproxy_user_domain": [],
|
||||||
"cfproxy_user_domain": "",
|
"cfproxy_worker_domain": [],
|
||||||
|
"ws_keepalive_interval": 30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
utils/diagnostics.py
Normal file
57
utils/diagnostics.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from typing import Optional, Tuple, Callable
|
||||||
|
|
||||||
|
|
||||||
|
MSG_PORT_BUSY = (
|
||||||
|
"Не удалось запустить прокси:\n"
|
||||||
|
"Порт уже используется другим приложением.\n\n"
|
||||||
|
"Закройте приложение, использующее этот порт, "
|
||||||
|
"или измените порт в настройках прокси и перезапустите."
|
||||||
|
)
|
||||||
|
|
||||||
|
MSG_PERMISSION = (
|
||||||
|
"Не удалось запустить прокси:\n"
|
||||||
|
"Доступ к адресу/порту запрещён "
|
||||||
|
"(брандмауэр, антивирус или права доступа).\n\n"
|
||||||
|
"Измените порт на случайный в диапазоне 10000–50000 в настройках, "
|
||||||
|
"проверьте брандмауэр/антивирус и перезапустите."
|
||||||
|
)
|
||||||
|
|
||||||
|
MSG_BAD_ADDRESS = (
|
||||||
|
"Не удалось запустить прокси:\n"
|
||||||
|
"Некорректный или недоступный адрес для прослушивания.\n\n"
|
||||||
|
"Проверьте решение по открывшейся в браузере ссылке.\n"
|
||||||
|
"Проверьте host и порт в настройках прокси и перезапустите."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
|
||||||
|
_WSA_EACCES = 10013
|
||||||
|
_WSA_EFAULT = 10014
|
||||||
|
_WSA_EADDRINUSE = 10048
|
||||||
|
_WSA_EADDRNOTAVAIL = 10049
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
|
||||||
|
"""Map a listen-socket bind failure to a user-facing message.
|
||||||
|
|
||||||
|
Returns None when the exception is not a recognizable bind failure,
|
||||||
|
so callers can fall back to generic handling.
|
||||||
|
"""
|
||||||
|
if not isinstance(exc, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
err = exc.errno
|
||||||
|
winerror = getattr(exc, "winerror", None)
|
||||||
|
|
||||||
|
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
|
||||||
|
return MSG_PORT_BUSY, None
|
||||||
|
if err == errno.EACCES or winerror == _WSA_EACCES:
|
||||||
|
return MSG_PERMISSION, None
|
||||||
|
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
|
||||||
|
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
|
||||||
|
return MSG_BAD_ADDRESS, lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
|
||||||
|
return None, None
|
||||||
39
utils/logging_setup.py
Normal file
39
utils/logging_setup.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Shared construction of the rotating log file handler.
|
||||||
|
|
||||||
|
Centralizes the rotation invariant so both the tray and the CLI log paths
|
||||||
|
behave identically and the file can never grow without bound (issue #885).
|
||||||
|
|
||||||
|
A ``RotatingFileHandler`` only rotates when ``backupCount >= 1``: CPython's
|
||||||
|
``doRollover`` skips the entire rotation block when ``backupCount == 0``, so
|
||||||
|
``maxBytes`` is silently ignored and the active file grows forever. We force
|
||||||
|
at least one backup here regardless of caller input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging.handlers
|
||||||
|
|
||||||
|
|
||||||
|
_MIN_BYTES = 32 * 1024
|
||||||
|
_MIN_BACKUPS = 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_handler(
|
||||||
|
path: str,
|
||||||
|
log_max_mb: float = 5,
|
||||||
|
backups: int = 1,
|
||||||
|
) -> logging.handlers.RotatingFileHandler:
|
||||||
|
"""Create a RotatingFileHandler that actually rotates.
|
||||||
|
|
||||||
|
``backups`` is clamped to at least 1 so rotation is always active, and
|
||||||
|
``maxBytes`` keeps a small floor so a misconfigured tiny size can't cause
|
||||||
|
rotation on every line.
|
||||||
|
"""
|
||||||
|
max_bytes = max(_MIN_BYTES, int(log_max_mb * 1024 * 1024))
|
||||||
|
backup_count = max(_MIN_BACKUPS, int(backups))
|
||||||
|
return logging.handlers.RotatingFileHandler(
|
||||||
|
path,
|
||||||
|
maxBytes=max_bytes,
|
||||||
|
backupCount=backup_count,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -14,16 +14,19 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||||
from proxy.tg_ws_proxy import _run
|
from proxy.tg_ws_proxy import _run
|
||||||
from utils.default_config import default_tray_config
|
from utils.default_config import default_tray_config
|
||||||
|
from utils.diagnostics import diagnose_listen_error
|
||||||
|
from utils.logging_setup import build_log_handler
|
||||||
|
|
||||||
log = logging.getLogger("tg-ws-tray")
|
log = logging.getLogger("tg-ws-tray")
|
||||||
|
|
||||||
APP_NAME = "TgWsProxy"
|
APP_NAME = "TgWsProxy"
|
||||||
|
PORTABLE_DIR_NAME = "TgWsProxy_data"
|
||||||
|
|
||||||
|
|
||||||
def _app_dir() -> Path:
|
def _standard_app_dir() -> Path:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
@@ -31,6 +34,61 @@ def _app_dir() -> Path:
|
|||||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _exe_dir() -> Optional[Path]:
|
||||||
|
try:
|
||||||
|
base = getattr(sys, "frozen", False) and sys.executable or sys.argv[0]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
p = Path(base).resolve()
|
||||||
|
return p.parent if p.is_file() else p
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_portable() -> Optional[Path]:
|
||||||
|
exe_dir = _exe_dir()
|
||||||
|
if exe_dir is None:
|
||||||
|
return None
|
||||||
|
portable_dir = exe_dir / PORTABLE_DIR_NAME
|
||||||
|
if "--portable" in sys.argv:
|
||||||
|
try:
|
||||||
|
portable_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot create portable dir %s: %s", portable_dir, repr(exc))
|
||||||
|
return None
|
||||||
|
if portable_dir.is_dir():
|
||||||
|
_migrate_into_portable(portable_dir)
|
||||||
|
return portable_dir
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_into_portable(portable_dir: Path) -> None:
|
||||||
|
try:
|
||||||
|
if any(portable_dir.iterdir()):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
std = _standard_app_dir()
|
||||||
|
if not std.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for src in std.iterdir():
|
||||||
|
if ".log" in src.name:
|
||||||
|
continue
|
||||||
|
dst = portable_dir / src.name
|
||||||
|
try:
|
||||||
|
if not src.is_dir():
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Portable migration: skip %s: %s", src.name, repr(exc))
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Portable migration failed: %s", repr(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _app_dir() -> Path:
|
||||||
|
return _detect_portable() or _standard_app_dir()
|
||||||
|
|
||||||
|
|
||||||
APP_DIR = _app_dir()
|
APP_DIR = _app_dir()
|
||||||
CONFIG_FILE = APP_DIR / "config.json"
|
CONFIG_FILE = APP_DIR / "config.json"
|
||||||
LOG_FILE = APP_DIR / "proxy.log"
|
LOG_FILE = APP_DIR / "proxy.log"
|
||||||
@@ -155,12 +213,7 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
|||||||
root.setLevel(level)
|
root.setLevel(level)
|
||||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||||
|
|
||||||
fh = logging.handlers.RotatingFileHandler(
|
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
|
||||||
str(LOG_FILE),
|
|
||||||
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
|
|
||||||
backupCount=0,
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
fh.setLevel(logging.DEBUG)
|
fh.setLevel(logging.DEBUG)
|
||||||
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||||
root.addHandler(fh)
|
root.addHandler(fh)
|
||||||
@@ -231,7 +284,7 @@ _proxy_thread: Optional[threading.Thread] = None
|
|||||||
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
||||||
|
|
||||||
|
|
||||||
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||||
global _async_stop
|
global _async_stop
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
@@ -243,13 +296,11 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
|||||||
loop.run_until_complete(_run(stop_event=stop_ev))
|
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Proxy thread crashed: %s", repr(exc))
|
log.error("Proxy thread crashed: %s", repr(exc))
|
||||||
if "Address already in use" in str(exc) or "10048" in str(exc):
|
msg, diagnose_called = diagnose_listen_error(exc)
|
||||||
on_port_busy(
|
if msg:
|
||||||
"Не удалось запустить прокси:\n"
|
show_error(msg)
|
||||||
"Порт уже используется другим приложением.\n\n"
|
if diagnose_called:
|
||||||
"Закройте приложение, использующее этот порт, "
|
diagnose_called()
|
||||||
"или измените порт в настройках прокси и перезапустите."
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
_async_stop = None
|
_async_stop = None
|
||||||
@@ -271,8 +322,9 @@ 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.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
|
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
|
||||||
pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
|
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
|
||||||
|
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
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, Tuple
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request
|
||||||
|
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"
|
||||||
@@ -36,13 +36,8 @@ _state: Dict[str, Any] = {
|
|||||||
|
|
||||||
def _cache_file() -> Optional[Path]:
|
def _cache_file() -> Optional[Path]:
|
||||||
try:
|
try:
|
||||||
if sys.platform == "win32":
|
from utils.tray_common import APP_DIR
|
||||||
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy"
|
root = APP_DIR
|
||||||
elif sys.platform == "darwin":
|
|
||||||
root = Path.home() / "Library/Application Support/TgWsProxy"
|
|
||||||
else:
|
|
||||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
||||||
root = (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "TgWsProxy"
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
return root / ".update_check_cache.json"
|
return root / ".update_check_cache.json"
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -135,7 +130,7 @@ def fetch_latest_release(
|
|||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||||
code = getattr(resp, "status", None) or resp.getcode()
|
code = getattr(resp, "status", None) or resp.getcode()
|
||||||
new_etag = resp.headers.get("ETag")
|
new_etag = resp.headers.get("ETag")
|
||||||
raw = resp.read().decode("utf-8", errors="replace")
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
@@ -257,19 +252,29 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback
|
# Fallback
|
||||||
|
import platform
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
is_64 = struct.calcsize("P") * 8 == 64
|
is_64 = struct.calcsize("P") * 8 == 64
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
is_arm64 = machine in ("arm64", "aarch64")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
is_modern = sys.getwindowsversion().major >= 10
|
is_modern = sys.getwindowsversion().major >= 10
|
||||||
except Exception:
|
except Exception:
|
||||||
is_modern = True
|
is_modern = True
|
||||||
if is_modern:
|
|
||||||
|
if is_arm64:
|
||||||
|
name = "TgWsProxy_windows_arm64.exe"
|
||||||
|
elif is_modern:
|
||||||
name = "TgWsProxy_windows.exe"
|
name = "TgWsProxy_windows.exe"
|
||||||
elif is_64:
|
elif is_64:
|
||||||
name = "TgWsProxy_windows_7_64bit.exe"
|
name = "TgWsProxy_windows_7_64bit.exe"
|
||||||
else:
|
else:
|
||||||
name = "TgWsProxy_windows_7_32bit.exe"
|
name = "TgWsProxy_windows_7_32bit.exe"
|
||||||
|
|
||||||
for a in assets:
|
for a in assets:
|
||||||
if a.get("name") == name:
|
if a.get("name") == name:
|
||||||
return a["url"], a["name"]
|
return a["url"], a["name"]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
15
windows.py
15
windows.py
@@ -8,8 +8,11 @@ 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
|
||||||
@@ -219,9 +222,6 @@ def update_ctk_form(
|
|||||||
|
|
||||||
|
|
||||||
def _perform_update(download_url: str, set_status=None) -> None:
|
def _perform_update(download_url: str, set_status=None) -> None:
|
||||||
import tempfile
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -244,7 +244,14 @@ def _perform_update(download_url: str, set_status=None) -> None:
|
|||||||
os.close(fd)
|
os.close(fd)
|
||||||
tmp_path = Path(tmp_name)
|
tmp_path = Path(tmp_name)
|
||||||
log.info("Downloading update from %s", download_url)
|
log.info("Downloading update from %s", download_url)
|
||||||
urllib.request.urlretrieve(download_url, str(tmp_path))
|
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(f"Не удалось скачать:\n{exc}")
|
_err(f"Не удалось скачать:\n{exc}")
|
||||||
if tmp_path:
|
if tmp_path:
|
||||||
|
|||||||
Reference in New Issue
Block a user