mirror of
https://github.com/by-sonic/tglock.git
synced 2026-07-30 17:36:10 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef81d91ad1 | |||
| ed121d0968 | |||
| 2d2a9e9302 | |||
| af3b14395f | |||
| 59b9cdd68c | |||
| f03e9106ee |
@@ -29,6 +29,26 @@ jobs:
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
# Оба бинаря лежат в одном крейте, и бандлер Tauri без явного указания
|
||||
# может взять headless CLI как главный исполняемый файл приложения.
|
||||
# Именно это уехало в v2.0.0-beta.2: .app и .deb содержали tglock-cli, и
|
||||
# приложение не открывалось ни на одной платформе.
|
||||
- name: Main binary of the bundle must be the GUI one
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const conf = JSON.parse(fs.readFileSync('tauri.conf.json', 'utf8'));
|
||||
const toml = fs.readFileSync('Cargo.toml', 'utf8');
|
||||
const gui = toml.split('[[bin]]').slice(1)
|
||||
.find(b => /required-features\s*=\s*\[\s*\"gui\"\s*\]/.test(b));
|
||||
if (!gui) throw new Error('не найден [[bin]] с required-features = [\"gui\"]');
|
||||
const name = (gui.match(/name\s*=\s*\"([^\"]+)\"/) || [])[1];
|
||||
if (conf.mainBinaryName !== name) {
|
||||
throw new Error('mainBinaryName=' + conf.mainBinaryName + ', а GUI-бинарь называется ' + name);
|
||||
}
|
||||
console.log('mainBinaryName указывает на GUI-бинарь:', name);
|
||||
"
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
@@ -38,6 +58,47 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --all-targets
|
||||
|
||||
bundle:
|
||||
name: Bundled app must be the GUI binary
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
|
||||
# Явный --target обязателен: без него бандлер выбирал правильный бинарь, а
|
||||
# с ним — нет, и именно так beta.2 и beta.3 уехали с headless CLI внутри.
|
||||
- name: Bundle the app
|
||||
run: npx tauri build --bundles app --target aarch64-apple-darwin
|
||||
|
||||
# Проверяется содержимое, а не имя файла. В beta.3 имя было уже
|
||||
# правильным, потому что бандлер переименовал CLI, и проверка имени
|
||||
# ничего не заметила.
|
||||
- name: The bundled binary must actually be the GUI one
|
||||
run: |
|
||||
exe=$(find target -maxdepth 8 -path '*TGLock.app/Contents/MacOS/tglock' | head -1)
|
||||
test -n "$exe" || { echo "исполняемый файл бандла не найден"; find target -name 'TGLock.app' -maxdepth 6; exit 1; }
|
||||
ls -la "$exe"
|
||||
if ! grep -qa 'ipc\.localhost' "$exe"; then
|
||||
echo "в бандле не GUI: отсутствует признак ipc.localhost"; exit 1
|
||||
fi
|
||||
if grep -qa 'allow-direct' "$exe"; then
|
||||
echo "в бандле оказался headless CLI: найден признак allow-direct"; exit 1
|
||||
fi
|
||||
echo "OK: в бандле GUI-бинарь"
|
||||
|
||||
headless:
|
||||
name: Headless CLI (no WebView, no Node)
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -53,13 +114,13 @@ jobs:
|
||||
# libwebkit2gtk. It fails the moment anything drags the GUI back into the
|
||||
# headless build, which is the whole point of issues #10 and #17.
|
||||
- name: Lint
|
||||
run: cargo clippy --no-default-features --lib --bins --all-targets -- -D warnings
|
||||
run: cargo clippy --no-default-features --features cli --all-targets -- -D warnings
|
||||
|
||||
- name: Test
|
||||
run: cargo test --no-default-features --lib --bins
|
||||
run: cargo test --no-default-features --features cli --lib --bins
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --no-default-features --bin tglock-cli
|
||||
run: cargo build --release --no-default-features --features cli --bin tglock-cli
|
||||
|
||||
- name: Start, advertise a proxy link and stop on SIGTERM
|
||||
run: |
|
||||
@@ -103,7 +164,7 @@ jobs:
|
||||
run: cargo check --locked
|
||||
|
||||
- name: Check the headless dependency graph too
|
||||
run: cargo check --locked --no-default-features --lib --bins
|
||||
run: cargo check --locked --no-default-features --features cli --lib --bins
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
|
||||
@@ -72,9 +72,52 @@ jobs:
|
||||
- Linux: скачайте `.AppImage` или `.deb`
|
||||
- Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Проверяет СОДЕРЖИМОЕ упакованного бинаря, а не его имя.
|
||||
#
|
||||
# v2.0.0-beta.2 и v2.0.0-beta.3 опубликовались с зелёным CI, хотя в бандле
|
||||
# лежал headless CLI вместо приложения. В beta.3 имя файла было уже
|
||||
# правильным — переименованным — поэтому проверка имени прошла. Отличить
|
||||
# можно только по содержимому: `wry`/`ipc.localhost` есть исключительно в
|
||||
# GUI, а `allow-direct` — исключительно в CLI.
|
||||
- name: The bundled binary must actually be the GUI one
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
app=$(find target -maxdepth 6 -name 'TGLock.app' -type d | head -1)
|
||||
test -n "$app" || { echo "TGLock.app не найден"; exit 1; }
|
||||
exe="$app/Contents/MacOS/tglock"
|
||||
test -f "$exe" || { echo "нет $exe:"; ls -la "$app/Contents/MacOS/"; exit 1; }
|
||||
ls -la "$exe"
|
||||
if ! grep -qa 'ipc\.localhost' "$exe"; then
|
||||
echo "в бандле не GUI: отсутствует признак ipc.localhost"; exit 1
|
||||
fi
|
||||
if grep -qa 'allow-direct' "$exe"; then
|
||||
echo "в бандле оказался headless CLI: найден признак allow-direct"; exit 1
|
||||
fi
|
||||
echo "OK: в бандле GUI-бинарь"
|
||||
|
||||
- name: The bundled binary must actually be the GUI one
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
deb=$(find target -maxdepth 6 -name '*.deb' | head -1)
|
||||
test -n "$deb" || { echo ".deb не найден"; exit 1; }
|
||||
root=$(mktemp -d)
|
||||
dpkg-deb -x "$deb" "$root"
|
||||
exe="$root/usr/bin/tglock"
|
||||
test -f "$exe" || { echo "нет /usr/bin/tglock:"; dpkg-deb -c "$deb" | grep '/bin/'; exit 1; }
|
||||
ls -la "$exe"
|
||||
if ! grep -qa 'ipc\.localhost' "$exe"; then
|
||||
echo "в .deb не GUI: отсутствует признак ipc.localhost"; exit 1
|
||||
fi
|
||||
if grep -qa 'allow-direct' "$exe"; then
|
||||
echo "в .deb оказался headless CLI: найден признак allow-direct"; exit 1
|
||||
fi
|
||||
echo "OK: в .deb GUI-бинарь"
|
||||
|
||||
cli:
|
||||
name: Headless CLI ${{ matrix.platform }}
|
||||
needs: publish
|
||||
@@ -113,7 +156,7 @@ jobs:
|
||||
IFS=',' read -ra targets <<< "${{ matrix.rust-targets }}"
|
||||
binaries=()
|
||||
for target in "${targets[@]}"; do
|
||||
cargo build --release --locked --no-default-features \
|
||||
cargo build --release --locked --no-default-features --features cli \
|
||||
--bin tglock-cli --target "$target"
|
||||
binaries+=("target/$target/release/tglock-cli")
|
||||
done
|
||||
@@ -129,7 +172,7 @@ jobs:
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --locked --no-default-features \
|
||||
cargo build --release --locked --no-default-features --features cli \
|
||||
--bin tglock-cli --target ${{ matrix.rust-targets }}
|
||||
cp "target/${{ matrix.rust-targets }}/release/tglock-cli.exe" "${{ matrix.asset }}"
|
||||
./"${{ matrix.asset }}" --version
|
||||
@@ -140,5 +183,5 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
prerelease: true
|
||||
prerelease: false
|
||||
files: ${{ matrix.asset }}
|
||||
|
||||
Generated
+1
-1
@@ -3553,7 +3553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tglock"
|
||||
version = "2.0.0-beta.2"
|
||||
version = "2.0.0-beta.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cipher",
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tglock"
|
||||
version = "2.0.0-beta.2"
|
||||
version = "2.0.0-beta.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
description = "Telegram unblock via local WebSocket tunnel"
|
||||
@@ -13,6 +13,14 @@ default = ["gui"]
|
||||
# frontend bundle from the build, which is what makes headless servers and
|
||||
# machines without a GPU or monitor able to build and run TGLock at all.
|
||||
gui = ["dep:tauri", "dep:tauri-build", "dep:open"]
|
||||
# The headless binary. Deliberately NOT in `default`.
|
||||
#
|
||||
# When both binaries exist in one build, the Tauri bundler picks the wrong one
|
||||
# as the application: `tauri build --target <triple>` packaged tglock-cli and
|
||||
# shipped it as the app in v2.0.0-beta.2 and v2.0.0-beta.3. Keeping the CLI
|
||||
# behind its own non-default feature means it simply does not exist during an
|
||||
# application build, so there is nothing to pick wrongly.
|
||||
cli = ["dep:clap"]
|
||||
|
||||
[lib]
|
||||
name = "tglock"
|
||||
@@ -26,11 +34,12 @@ required-features = ["gui"]
|
||||
[[bin]]
|
||||
name = "tglock-cli"
|
||||
path = "src/bin/cli.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [], optional = true }
|
||||
open = { version = "5", optional = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
|
||||
@@ -80,22 +80,54 @@ TGLock — это **локальный прокси** на твоём компь
|
||||
|
||||
**[👉 Последний релиз](https://github.com/by-sonic/tglock/releases/latest)**
|
||||
|
||||
| Платформа | Файл | Размер |
|
||||
|---|---|---|
|
||||
| **Windows 10/11** (x64) | `_x64-setup.exe` | ~2 МБ |
|
||||
| **macOS** (Apple Silicon + Intel) | universal `.dmg` | ~7 МБ |
|
||||
| **Linux** (x86_64) | `.deb` | ~3 МБ |
|
||||
| **Linux** (x86_64, портативно) | `.AppImage` | ~79 МБ |
|
||||
| Платформа | Файл |
|
||||
|---|---|
|
||||
| **Windows 10/11** (x64) | `_x64-setup.exe` |
|
||||
| **macOS** (Apple Silicon + Intel) | universal `.dmg` |
|
||||
| **Linux** (x86_64) | `.deb` |
|
||||
| **Linux** (x86_64, портативно) | `.AppImage` |
|
||||
| **Сервер, контейнер, машина без монитора** | `tglock-cli-*` |
|
||||
|
||||
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно просто не создаётся: сервер, контейнер, виртуалка, машина без монитора или без 3D-ускорения.
|
||||
>
|
||||
> Он есть в `main` и собирается одной командой, а в готовые сборки релиза попадёт начиная со следующего тега:
|
||||
>
|
||||
> ```bash
|
||||
> cargo build --release --locked --no-default-features --bin tglock-cli
|
||||
> ```
|
||||
>
|
||||
> Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
|
||||
Все сборки весят единицы мегабайт. Исключение — `.AppImage`: он несёт своё окружение и поэтому крупный.
|
||||
|
||||
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно не создаётся: сервер, контейнер, виртуалка, машина без монитора. Доступен начиная с `v2.0.0-beta.2`. Если ты скачал beta.2 и приложение не открывалось — это была ошибка сборки, исправлено в beta.3. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
|
||||
|
||||
### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты
|
||||
|
||||
Так и будет. Объясню механизм и дам способы проверить, не доверяя мне на слово.
|
||||
|
||||
**Что ты увидишь.** На Windows — «Система Windows защитила ваш компьютер» от SmartScreen. На VirusTotal — детекты у части движков, обычно единицы из примерно шестидесяти.
|
||||
|
||||
**Почему.** Складываются две вещи. Установщик не подписан сертификатом, а неподписанные файлы проверяются эвристиками гораздо строже, чем подписанные. И само поведение программы — открыть локальный порт, объявить себя прокси-сервером, прописаться в настройки соединения Telegram — это ровно тот профиль, по которому эвристики ищут прокси-трояны. Программа делает именно это, только по твоей просьбе. Отличить одно от другого автоматически движок не может, поэтому и реагирует.
|
||||
|
||||
**Подписи не будет.** Сертификат — это ежегодный платёж, а проект бесплатный и ничего не зарабатывает. Значит предупреждение останется, и делать вид, что «скоро подпишем», я не буду.
|
||||
|
||||
**Как проверить вместо доверия.** Три способа, все не требуют верить мне:
|
||||
|
||||
1. **Сверить контрольную сумму.** GitHub публикует `sha256` каждого файла прямо на [странице релиза](https://github.com/by-sonic/tglock/releases/latest) — разверни `Assets` и увидишь digest рядом с именем. Сравни с тем, что скачалось:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\TGLock_<версия>_x64-setup.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
```bash
|
||||
sha256sum tglock-cli-x86_64-unknown-linux-gnu # Linux
|
||||
shasum -a 256 tglock-cli-universal-apple-darwin # macOS
|
||||
```
|
||||
|
||||
Это доказывает, что файл не подменили по пути к тебе.
|
||||
|
||||
2. **Посмотреть, как файл собирался.** Бинарники собирает GitHub Actions из публичного коммита, лог открыт и его никто не может отредактировать задним числом. У каждого релиза на странице Actions есть свой запуск: видно, какой коммит взят и какими командами собран. Ссылка на него — в описании релиза.
|
||||
|
||||
3. **Собрать самому.** Для CLI это одна команда и никаких зависимостей кроме Rust:
|
||||
|
||||
```bash
|
||||
cargo build --release --locked --no-default-features --features cli --bin tglock-cli
|
||||
```
|
||||
|
||||
Полная сборка с интерфейсом — [ниже](#-сборка-из-исходников).
|
||||
|
||||
**Если этого недостаточно — не запускай.** Это нормальное решение, а не паранойя: исполняемый файл из интернета без подписи заслуживает недоверия по умолчанию. Собери из исходников или возьми [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy) — он решает ту же задачу и тоже открыт.
|
||||
|
||||
> **🍎 macOS:** пока сборка не нотарифицирована Apple, при первом запуске может понадобиться:
|
||||
> ```bash
|
||||
@@ -137,7 +169,7 @@ Telegram → Настройки → **Продвинутые** → Тип сое
|
||||
tglock-cli # 127.0.0.1:1080, только для этого компьютера
|
||||
tglock-cli --lan # 0.0.0.0:1080, только адреса Telegram
|
||||
tglock-cli --bind 10.0.0.5 --port 1443 # свой адрес и порт
|
||||
tglock-cli --worker my-name.workers.dev # резервный маршрут через свой Cloudflare Worker
|
||||
tglock-cli --worker my-name.workers.dev # резервный маршрут, см. docs/CLOUDFLARE_WORKER.md
|
||||
tglock-cli --help # все флаги
|
||||
```
|
||||
|
||||
@@ -188,7 +220,7 @@ journalctl -u tglock -f
|
||||
FROM rust:1.88 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN cargo build --release --locked --no-default-features --bin tglock-cli
|
||||
RUN cargo build --release --locked --no-default-features --features cli --bin tglock-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
@@ -233,7 +265,7 @@ Telegram Desktop / mobile (через LAN)
|
||||
|
||||
> Интерфейс различает три состояния и не выдаёт одно за другое: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«Telegram на связи»** — есть установленный туннель, то есть WebSocket-рукопожатие уже прошло. Смешивание первого и третьего состояния и было основной причиной жалоб «прокси подключён, а Telegram не работает».
|
||||
|
||||
📖 **Архитектура 2.0, различение протоколов и честный список ограничений** — [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md). Разбор всех issue и того, что в них было обещано зря — [docs/ISSUE_AUDIT.md](docs/ISSUE_AUDIT.md). Черновик статьи про переход v1 → v2 лежит в [HABR.md](HABR.md) — цифры там описывают код на момент написания, документацией он не является.
|
||||
📖 **Архитектура 2.0, различение протоколов и честный список ограничений** — [docs/ARCHITECTURE_V2.md](docs/ARCHITECTURE_V2.md). Запасной маршрут через свой Cloudflare Worker, со скриптом и пошаговой установкой — [docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md). Разбор всех issue и того, что в них было обещано зря — [docs/ISSUE_AUDIT.md](docs/ISSUE_AUDIT.md). Черновик статьи про переход v1 → v2 лежит в [HABR.md](HABR.md) — цифры там описывают код на момент написания, документацией он не является.
|
||||
|
||||
---
|
||||
|
||||
@@ -344,9 +376,13 @@ xattr -cr /Applications/TGLock.app
|
||||
|
||||
Это реальный риск, и TGLock 2.0 к нему подготовлен настолько, насколько может.
|
||||
|
||||
Маршрут не один: пробуются закреплённые IP Telegram, дублёры `kwsN-1` и системный DNS. Пока жив хотя бы один — туннель поднимается. Дополнительно можно указать в настройках **свой Cloudflare Worker** — тогда соединение пойдёт через твой домен на `workers.dev`, и заблокировать его провайдеру придётся отдельно.
|
||||
Маршрут не один: пробуются закреплённые IP Telegram, дублёры `kwsN-1` и системный DNS. Пока жив хотя бы один — туннель поднимается.
|
||||
|
||||
Но честно: если веб-версию Telegram заблокируют целиком, подход исчерпает себя. TGLock держится на том, что `web.telegram.org` доступен, и никакой запас маршрутов этого не отменяет. В таком случае нужен полноценный VPN — это уже другая задача.
|
||||
Если у твоего провайдера легли **все** маршруты, есть запасной выход — **свой Cloudflare Worker**. Тогда соединение идёт на твой домен `*.workers.dev`, а воркер доводит его до Telegram; блокировать его провайдеру придётся отдельно. Готовый скрипт и пошаговая установка: **[docs/CLOUDFLARE_WORKER.md](docs/CLOUDFLARE_WORKER.md)**. Нужен только аккаунт Cloudflare, бесплатного тарифа хватает, свой сервер и домен не нужны.
|
||||
|
||||
Признак, что пора это делать: приложение показывает «Ищем новый маршрут» и не проходит, а в диагностике туннелей 0 и растёт счётчик сбоев. Если Telegram работает — настраивать ничего не надо.
|
||||
|
||||
Но честно: если веб-версию Telegram заблокируют так, что её не видно и из датацентров Cloudflare, подход исчерпает себя. TGLock держится на доступности `web.telegram.org`, и никакой запас маршрутов этого не отменяет.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -399,7 +435,7 @@ npm run tauri build
|
||||
### Только CLI, без графики
|
||||
|
||||
```bash
|
||||
cargo build --release --locked --no-default-features --bin tglock-cli
|
||||
cargo build --release --locked --no-default-features --features cli --bin tglock-cli
|
||||
```
|
||||
|
||||
Ни Node.js, ни фронтенда, ни `libwebkit2gtk` для этого не нужно — при выключенной фиче `gui` Tauri и системный WebView в сборку не попадают вообще. Именно так CLI собирается на голом сервере.
|
||||
@@ -409,9 +445,9 @@ cargo build --release --locked --no-default-features --bin tglock-cli
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo clippy --no-default-features --lib --bins --all-targets -- -D warnings
|
||||
cargo clippy --no-default-features --features cli --all-targets -- -D warnings
|
||||
cargo test --all-targets
|
||||
cargo test --no-default-features --lib --bins
|
||||
cargo test --no-default-features --features cli --lib --bins
|
||||
```
|
||||
|
||||
Тестов 59: разбор `obfuscated2`, каскад маршрутов и его cooldown, протокольные отказы SOCKS5, устойчивость секрета к перезапуску, плюс сквозной тест туннеля против мок-сервера, который реализует сторону Telegram и проверяет, что до неё доходит ровно тот открытый текст, который отправил клиент. Единственный тест с пометкой `#[ignore]` — тот, что требует живой сети.
|
||||
|
||||
@@ -118,10 +118,14 @@ Worker должен принимать WebSocket на:
|
||||
заданные оператором значения не перезаписываются, а `TGLOCK_FORCE_GPU=1`
|
||||
возвращает аппаратное ускорение. Для машин без монитора остаётся
|
||||
`tglock-cli`, которому WebView не нужен вовсе (issues #10, #17).
|
||||
- Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована. Часть
|
||||
антивирусов реагирует на неподписанный установщик, открывающий локальный
|
||||
сокет; проверяемый ответ — сборка из исходников либо публичные логи
|
||||
GitHub Actions.
|
||||
- Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована, и
|
||||
подписывать их не планируется: сертификат — ежегодный платёж, а проект
|
||||
бесплатный. Часть антивирусов будет реагировать на неподписанный установщик,
|
||||
который открывает локальный сокет и прописывается прокси-сервером — это тот
|
||||
же профиль, по которому ищут прокси-трояны. Вместо доверия предлагаются
|
||||
проверяемые пути: `sha256` каждого артефакта публикуется GitHub на странице
|
||||
релиза, сборка идёт в GitHub Actions из публичного коммита с открытым логом,
|
||||
CLI собирается одной командой. Подробно — в разделе README про антивирус.
|
||||
- Работоспособность медиа зависит от конкретного DC аккаунта и доступности
|
||||
Telegram/Cloudflare у провайдера.
|
||||
- Пулы заранее открытых WebSocket-соединений будут добавлены после измерения,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Резервный маршрут через свой Cloudflare Worker
|
||||
|
||||
## Когда это нужно
|
||||
|
||||
Только в одном случае: провайдер заблокировал саму веб-инфраструктуру Telegram, и **все** обычные маршруты TGLock перестали отвечать.
|
||||
|
||||
Как это выглядит в приложении:
|
||||
|
||||
| Что видно | Что это значит | Нужен ли Worker |
|
||||
|---|---|---|
|
||||
| «Telegram на связи» | туннель работает | нет |
|
||||
| «Ищем новый маршрут» и не проходит | маршруты перебираются и все падают | **да** |
|
||||
| «Защита включена», DC не определяется | Telegram ещё не подключался | нет, открой Telegram |
|
||||
| В диагностике счётчик сбоев растёт, туннелей 0 | ни один маршрут не отвечает | **да** |
|
||||
|
||||
В CLI то же самое видно в строке статуса: `туннелей 0 · DC не определён · сбоев 14`.
|
||||
|
||||
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
||||
|
||||
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- аккаунт Cloudflare (бесплатного тарифа достаточно);
|
||||
- 10 минут.
|
||||
|
||||
Ни своего сервера, ни домена, ни карты не нужно — воркер получит адрес вида `имя.твой-логин.workers.dev`.
|
||||
|
||||
## Установка через веб-интерфейс
|
||||
|
||||
1. Зайди на [dash.cloudflare.com](https://dash.cloudflare.com) → **Workers & Pages** → **Create application** → **Create Worker**.
|
||||
2. Дай имя, например `tglock`. Нажми **Deploy** — сначала задеплоится заготовка, это нормально.
|
||||
3. Нажми **Edit code**.
|
||||
4. Удали всё содержимое редактора и вставь файл [`worker/tglock-worker.js`](../worker/tglock-worker.js) из этого репозитория целиком.
|
||||
5. **Deploy**.
|
||||
6. Скопируй адрес воркера. Он показан сверху и выглядит как `tglock.имя.workers.dev` — **без** `https://` и без пути.
|
||||
|
||||
### Проверка, что воркер жив
|
||||
|
||||
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` — это правильный ответ: значит код развёрнут и работает, просто браузер пришёл обычным запросом.
|
||||
|
||||
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
||||
|
||||
## Подключение в TGLock
|
||||
|
||||
**В приложении:** Настройки → поле **Cloudflare Worker** → вставь `tglock.имя.workers.dev` → Сохранить. Настройки меняются только при выключенной защите.
|
||||
|
||||
**В CLI:** флаг `--worker`, можно повторять:
|
||||
|
||||
```bash
|
||||
tglock-cli --worker tglock.имя.workers.dev
|
||||
tglock-cli --worker первый.workers.dev --worker второй.workers.dev
|
||||
```
|
||||
|
||||
Worker всегда пробуется **последним**, после всех маршрутов Telegram. Пока обычные маршруты живы, трафик через него не пойдёт, и это осознанно: чужая инфраструктура в цепочке — это лишнее звено, а не улучшение.
|
||||
|
||||
## Ограничение доступа
|
||||
|
||||
Адрес воркера сам по себе секрет, но лучше поставить токен: **Settings → Variables → Add variable**, имя `TGLOCK_TOKEN`, значение — любая длинная строка.
|
||||
|
||||
Пока переменная не задана, проверка токена выключена. Когда задана — воркер начнёт отвечать `403` без параметра `?token=`. Клиент TGLock этот параметр пока не отправляет, так что включать токен есть смысл, если ты правишь и сам скрипт, и адрес.
|
||||
|
||||
Независимо от токена воркер соединяется **только** с семью адресами Telegram, которые запрашивает TGLock. Любой другой `dst` получает `403`, так что открытым TCP-прокси он не станет.
|
||||
|
||||
## Контракт
|
||||
|
||||
Если захочешь написать свою реализацию — вот что именно делает клиент (`src/transport.rs`):
|
||||
|
||||
```text
|
||||
wss://<домен>/apiws?dst=<telegram-ip>&dc=<номер-dc>
|
||||
Sec-WebSocket-Protocol: binary
|
||||
```
|
||||
|
||||
- `dst` — адрес Telegram, к которому нужно подключиться по TCP на порт 443;
|
||||
- `dc` — номер датацентра, для логов;
|
||||
- **подпротокол `binary` обязательно нужно подтвердить в ответе** — без этого клиент разорвёт рукопожатие;
|
||||
- дальше бинарные frames пересылаются в обе стороны без изменений;
|
||||
- TLS до самого воркера обеспечивает Cloudflare.
|
||||
|
||||
Список допустимых `dst` совпадает с `transport::worker_allowed_destinations()`.
|
||||
|
||||
## Честно про проверку
|
||||
|
||||
Скрипт написан по контракту, вычитанному из исходников клиента, и путь с параметрами закреплён тестом `connects_through_the_documented_worker_contract` — он поднимает локальный сервер, который ведёт себя ровно так, как описано выше, и проверяет, что туннель через него поднимается и данные доходят в обе стороны.
|
||||
|
||||
Чего этот тест не проверяет: развёрнутый воркер в самом Cloudflare. Если что-то не сойдётся с их API — [открой issue](https://github.com/by-sonic/tglock/issues/new), поправлю.
|
||||
+15
-5
@@ -14,11 +14,21 @@
|
||||
> больше нет, и правил системный DNS. Взято разделение GUI/CLI и произвольный
|
||||
> bind-адрес; DNS-менеджмент и проверка root отброшены как ненужные.
|
||||
> - #12 закрыт: относился к шрифту старого egui-интерфейса.
|
||||
> - #10 и #17 **оставлены открытыми осознанно.** Появился headless `tglock-cli`,
|
||||
> который на таких машинах работает, но сам GUI по-прежнему не создаёт окно
|
||||
> без 3D-ускорения. Это обход, а не исправление.
|
||||
> - #9 (Android) остаётся в backlog без сроков, #21 ждёт подтверждения на
|
||||
> пересобранной сборке macOS.
|
||||
> - #10 и #17 закрыты выпуском `v2.0.0-beta.2`: GUI перед стартом просит у
|
||||
> WebView программный рендер. Проверить это на машине без 3D-ускорения
|
||||
> возможности не было, поэтому закрыто как «исправление выпущено», а не
|
||||
> «исправлено» — репортерам предложено переоткрыть, если проблема осталась.
|
||||
> Независимо от WebView работает `tglock-cli`.
|
||||
> - #21 закрыт: репорт относился к сборке macOS, которой больше нет, в
|
||||
> `v2.0.0-beta.2` она пересобрана универсальным `.dmg`.
|
||||
> - #9 (Android) остаётся единственным открытым — backlog без сроков.
|
||||
>
|
||||
> Претензия из публичного обсуждения, которую нельзя закрыть кодом: инсталлятор
|
||||
> не подписан, из-за чего часть антивирусов на него реагирует. Решение принято
|
||||
> и зафиксировано: подписи не будет, сертификат — ежегодный платёж, а проект
|
||||
> бесплатный. Вместо неё в README описан механизм срабатывания и три
|
||||
> проверяемых пути — сверка `sha256` с публикуемым GitHub digest, открытый лог
|
||||
> сборки в Actions и сборка из исходников одной командой.
|
||||
>
|
||||
> Дополнительно исправлено то, чего в issues не было: коллизия MTProto-init с
|
||||
> байтом `0x05` (одно соединение из 256 уходило в SOCKS5-ветку и умирало),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tglock-ui",
|
||||
"private": true,
|
||||
"version": "2.0.0-beta.2",
|
||||
"version": "2.0.0-beta.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 1420",
|
||||
|
||||
+71
-10
@@ -561,31 +561,38 @@ mod tests {
|
||||
/// Stand-in for `kwsN.web.telegram.org`: a plaintext WebSocket that behaves
|
||||
/// like an obfuscated2 relay.
|
||||
///
|
||||
/// Returns the raw init frame it was handed and the plaintext it recovered,
|
||||
/// so a test can assert on what Telegram would really have seen.
|
||||
/// Returns the URI it was asked for, the raw init frame it was handed and
|
||||
/// the plaintext it recovered, so a test can assert on what Telegram — or a
|
||||
/// Cloudflare Worker standing in for it — would really have seen.
|
||||
// The handshake callback's error type is tungstenite's own `ErrorResponse`,
|
||||
// whose size is not ours to change.
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn mock_relay(
|
||||
listener: TcpListener,
|
||||
response: Vec<u8>,
|
||||
) -> Result<(Vec<u8>, Vec<u8>), String> {
|
||||
) -> Result<(String, Vec<u8>, Vec<u8>), String> {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
let (tcp, _) = listener.accept().await.map_err(|e| e.to_string())?;
|
||||
let requested = Arc::new(Mutex::new(String::new()));
|
||||
let seen = requested.clone();
|
||||
// Telegram confirms the `binary` subprotocol the proxy asks for, and
|
||||
// tungstenite refuses a handshake that silently drops it. A mock that
|
||||
// does not answer it would only ever test the failure path.
|
||||
let mut websocket =
|
||||
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
|
||||
let mut websocket = tokio_tungstenite::accept_hdr_async(
|
||||
tcp,
|
||||
move |request: &Request, mut response: Response| {
|
||||
*seen.lock().unwrap() = request.uri().to_string();
|
||||
response.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
"binary".parse().expect("static header value"),
|
||||
);
|
||||
Ok(response)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let requested = requested.lock().unwrap().clone();
|
||||
|
||||
let init = match websocket.next().await {
|
||||
Some(Ok(Message::Binary(data))) => data,
|
||||
@@ -615,7 +622,7 @@ mod tests {
|
||||
.send(Message::Binary(wire))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((init, request))
|
||||
Ok((requested, init, request))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -896,7 +903,7 @@ mod tests {
|
||||
"the client must see Telegram's plaintext"
|
||||
);
|
||||
|
||||
let (init_frame, relayed) = relay.await.unwrap().unwrap();
|
||||
let (_, init_frame, relayed) = relay.await.unwrap().unwrap();
|
||||
assert_eq!(init_frame.len(), INIT_LEN);
|
||||
assert_ne!(
|
||||
init_frame.as_slice(),
|
||||
@@ -919,6 +926,60 @@ mod tests {
|
||||
let _ = server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connects_through_the_documented_worker_contract() {
|
||||
// Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that
|
||||
// implements exactly what is documented there must carry a working
|
||||
// tunnel, and must be asked for exactly the documented URI.
|
||||
let dc = 2;
|
||||
let path = crate::transport::worker_path(dc).unwrap();
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let worker_port = listener.local_addr().unwrap().port();
|
||||
let response = b"an answer relayed by the worker".to_vec();
|
||||
let worker = tokio::spawn(mock_relay(listener, response.clone()));
|
||||
|
||||
let stats = Stats::new();
|
||||
stats.transport.force_local_route_with(
|
||||
worker_port,
|
||||
crate::transport::RouteKind::CloudflareWorker,
|
||||
path.clone(),
|
||||
);
|
||||
let (port, server) = start_proxy(stats.clone(), false).await;
|
||||
|
||||
let init = unambiguous_client_init(&stats.secret, dc as i16);
|
||||
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
|
||||
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
client.write_all(&init).await.unwrap();
|
||||
|
||||
let request = b"a request relayed to the worker".to_vec();
|
||||
let mut wire = request.clone();
|
||||
peer.encrypt(&mut wire);
|
||||
client.write_all(&wire).await.unwrap();
|
||||
|
||||
let mut received = vec![0; response.len()];
|
||||
tokio::time::timeout(Duration::from_secs(10), client.read_exact(&mut received))
|
||||
.await
|
||||
.expect("the worker's answer must come back through the tunnel")
|
||||
.unwrap();
|
||||
peer.decrypt(&mut received);
|
||||
assert_eq!(received, response);
|
||||
|
||||
let (requested, _, relayed) = worker.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
requested, path,
|
||||
"a deployed worker must serve exactly the documented path and query"
|
||||
);
|
||||
assert_eq!(relayed, request);
|
||||
assert_eq!(
|
||||
stats.last_route.load(Ordering::Relaxed),
|
||||
crate::transport::RouteKind::CloudflareWorker.ui_code()
|
||||
);
|
||||
|
||||
stats.stop();
|
||||
let _ = server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tunnel_counts_only_after_the_handshake_succeeds() {
|
||||
// Accepts TCP and then stays silent, so the WebSocket handshake never
|
||||
|
||||
+64
-4
@@ -122,11 +122,15 @@ impl TransportEngine {
|
||||
/// Point every data centre at a local plaintext WebSocket server so the
|
||||
/// whole tunnel can be exercised without reaching Telegram.
|
||||
pub(crate) fn force_local_route(&self, port: u16) {
|
||||
self.force_local_route_with(port, RouteKind::TelegramIp, "/apiws".to_owned());
|
||||
}
|
||||
|
||||
pub(crate) fn force_local_route_with(&self, port: u16, kind: RouteKind, path: String) {
|
||||
*self.forced_routes.lock().unwrap() = vec![Route {
|
||||
connect_host: "127.0.0.1".to_owned(),
|
||||
websocket_host: format!("127.0.0.1:{}", port),
|
||||
path: "/apiws".to_owned(),
|
||||
kind: RouteKind::TelegramIp,
|
||||
path,
|
||||
kind,
|
||||
port,
|
||||
secure: false,
|
||||
}];
|
||||
@@ -233,14 +237,15 @@ impl TransportEngine {
|
||||
}
|
||||
|
||||
let mut routes = routes_for_dc(key.dc, key.media);
|
||||
let Some(destination) = telegram_ips(key.dc).first() else {
|
||||
let Some(path) = worker_path(key.dc) else {
|
||||
return routes;
|
||||
};
|
||||
for domain in self.worker_domains.lock().unwrap().iter() {
|
||||
let path = path.clone();
|
||||
routes.push(Route::https(
|
||||
domain.clone(),
|
||||
domain.clone(),
|
||||
format!("/apiws?dst={}&dc={}", destination, key.dc),
|
||||
path,
|
||||
RouteKind::CloudflareWorker,
|
||||
));
|
||||
}
|
||||
@@ -273,6 +278,28 @@ impl TransportEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Path a user's Cloudflare Worker must serve for the given data centre.
|
||||
///
|
||||
/// This is the contract documented in `docs/CLOUDFLARE_WORKER.md`; both the
|
||||
/// route builder and the tests derive the path from here so the documentation
|
||||
/// cannot drift away from what the client actually requests.
|
||||
pub(crate) fn worker_path(dc: u16) -> Option<String> {
|
||||
let destination = telegram_ips(dc).first()?;
|
||||
Some(format!("/apiws?dst={}&dc={}", destination, dc))
|
||||
}
|
||||
|
||||
/// Every address a Worker may be asked to reach, so a deployment can refuse
|
||||
/// anything else instead of becoming an open TCP proxy.
|
||||
pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
||||
let mut all: Vec<_> = [1, 2, 3, 4, 5, 203]
|
||||
.into_iter()
|
||||
.flat_map(|dc| telegram_ips(dc).iter().copied())
|
||||
.collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
all
|
||||
}
|
||||
|
||||
fn canonical_dc(dc: u16) -> u16 {
|
||||
if dc == 203 {
|
||||
2
|
||||
@@ -629,6 +656,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_worker_contract_matches_the_requested_path() {
|
||||
// docs/CLOUDFLARE_WORKER.md promises exactly this shape.
|
||||
assert_eq!(
|
||||
worker_path(2).unwrap(),
|
||||
"/apiws?dst=149.154.167.51&dc=2",
|
||||
"the documented contract must match what the client requests"
|
||||
);
|
||||
assert_eq!(
|
||||
worker_path(203).unwrap(),
|
||||
"/apiws?dst=91.105.192.100&dc=203"
|
||||
);
|
||||
assert_eq!(worker_path(42), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_allowlist_covers_every_address_a_route_can_ask_for() {
|
||||
let allowed = worker_allowed_destinations();
|
||||
for dc in [1, 2, 3, 4, 5, 203] {
|
||||
for ip in telegram_ips(dc) {
|
||||
assert!(
|
||||
allowed.contains(ip),
|
||||
"{ip} is reachable via a route but missing from the Worker allowlist"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
allowed.len(),
|
||||
7,
|
||||
"the allowlist in worker/tglock-worker.js must be updated alongside this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_codes_and_labels_round_trip() {
|
||||
for kind in [
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "TGLock",
|
||||
"version": "2.0.0-beta.2",
|
||||
"version": "2.0.0-beta.4",
|
||||
"identifier": "com.bysonic.tglock",
|
||||
"mainBinaryName": "tglock",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Резервный маршрут TGLock через Cloudflare Worker.
|
||||
//
|
||||
// Нужен в одном случае: провайдер заблокировал саму веб-инфраструктуру
|
||||
// Telegram, и все обычные маршруты TGLock перестали отвечать. Тогда соединение
|
||||
// идёт на твой домен *.workers.dev, а воркер доводит его до Telegram.
|
||||
//
|
||||
// Инструкция по установке: docs/CLOUDFLARE_WORKER.md
|
||||
//
|
||||
// Контракт, который ожидает клиент (src/transport.rs):
|
||||
// wss://<домен>/apiws?dst=<telegram-ip>&dc=<номер-dc>
|
||||
// заголовок Sec-WebSocket-Protocol: binary — его обязательно нужно
|
||||
// подтвердить в ответе, иначе клиент разорвёт рукопожатие;
|
||||
// бинарные frames в обе стороны, без обёрток.
|
||||
|
||||
import { connect } from "cloudflare:sockets";
|
||||
|
||||
// Только те адреса, которые запрашивает сам TGLock. Без этого списка любой,
|
||||
// кто узнает адрес воркера, получит через твой аккаунт произвольный
|
||||
// TCP-прокси.
|
||||
const ALLOWED_DESTINATIONS = new Set([
|
||||
"91.105.192.100",
|
||||
"149.154.167.51",
|
||||
"149.154.167.91",
|
||||
"149.154.167.220",
|
||||
"149.154.171.5",
|
||||
"149.154.175.50",
|
||||
"149.154.175.100",
|
||||
]);
|
||||
|
||||
const TELEGRAM_PORT = 443;
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname !== "/apiws") {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
||||
return new Response("expected a websocket upgrade", { status: 426 });
|
||||
}
|
||||
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
|
||||
// воркера, и посторонние подключиться не смогут.
|
||||
if (env.TGLOCK_TOKEN && url.searchParams.get("token") !== env.TGLOCK_TOKEN) {
|
||||
return new Response("forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const destination = url.searchParams.get("dst");
|
||||
if (!destination || !ALLOWED_DESTINATIONS.has(destination)) {
|
||||
return new Response("destination not allowed", { status: 403 });
|
||||
}
|
||||
|
||||
const [client, server] = Object.values(new WebSocketPair());
|
||||
server.accept();
|
||||
|
||||
const upstream = connect({ hostname: destination, port: TELEGRAM_PORT });
|
||||
const writer = upstream.writable.getWriter();
|
||||
let closed = false;
|
||||
|
||||
const shutdown = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
writer.close().catch(() => {});
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// соединение уже закрыто
|
||||
}
|
||||
};
|
||||
|
||||
server.addEventListener("message", (event) => {
|
||||
const chunk =
|
||||
event.data instanceof ArrayBuffer
|
||||
? new Uint8Array(event.data)
|
||||
: event.data;
|
||||
writer.write(chunk).catch(shutdown);
|
||||
});
|
||||
server.addEventListener("close", shutdown);
|
||||
server.addEventListener("error", shutdown);
|
||||
|
||||
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
|
||||
(async () => {
|
||||
const reader = upstream.readable.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
server.send(value);
|
||||
}
|
||||
} catch {
|
||||
// разрыв соединения — обычная ситуация, не ошибка
|
||||
}
|
||||
shutdown();
|
||||
})();
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
// Обязательно: клиент запрашивает подпротокол binary и без
|
||||
// подтверждения рвёт рукопожатие.
|
||||
headers: { "Sec-WebSocket-Protocol": "binary" },
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user