mirror of
https://github.com/by-sonic/tglock.git
synced 2026-07-31 07:45:13 +03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96eb62b097 | |||
| 4ae755c6ae | |||
| 00d24a8647 | |||
| ef81d91ad1 | |||
| ed121d0968 | |||
| 2d2a9e9302 | |||
| af3b14395f | |||
| 59b9cdd68c | |||
| f03e9106ee |
@@ -29,6 +29,26 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: rustfmt, clippy
|
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
|
- name: Check formatting
|
||||||
run: cargo fmt --check
|
run: cargo fmt --check
|
||||||
|
|
||||||
@@ -38,6 +58,40 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --all-targets
|
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 -maxdepth 6 -name 'TGLock.app'; exit 1; }
|
||||||
|
python3 scripts/verify_bundle_binary.py "$exe"
|
||||||
|
|
||||||
headless:
|
headless:
|
||||||
name: Headless CLI (no WebView, no Node)
|
name: Headless CLI (no WebView, no Node)
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@@ -53,13 +107,13 @@ jobs:
|
|||||||
# libwebkit2gtk. It fails the moment anything drags the GUI back into the
|
# libwebkit2gtk. It fails the moment anything drags the GUI back into the
|
||||||
# headless build, which is the whole point of issues #10 and #17.
|
# headless build, which is the whole point of issues #10 and #17.
|
||||||
- name: Lint
|
- 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
|
- name: Test
|
||||||
run: cargo test --no-default-features --lib --bins
|
run: cargo test --no-default-features --features cli --lib --bins
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Start, advertise a proxy link and stop on SIGTERM
|
||||||
run: |
|
run: |
|
||||||
@@ -103,7 +157,7 @@ jobs:
|
|||||||
run: cargo check --locked
|
run: cargo check --locked
|
||||||
|
|
||||||
- name: Check the headless dependency graph too
|
- 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:
|
frontend:
|
||||||
name: Frontend
|
name: Frontend
|
||||||
|
|||||||
@@ -72,9 +72,38 @@ jobs:
|
|||||||
- Linux: скачайте `.AppImage` или `.deb`
|
- Linux: скачайте `.AppImage` или `.deb`
|
||||||
- Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса
|
- Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: true
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
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; }
|
||||||
|
python3 scripts/verify_bundle_binary.py "$exe"
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
python3 scripts/verify_bundle_binary.py "$exe"
|
||||||
|
|
||||||
cli:
|
cli:
|
||||||
name: Headless CLI ${{ matrix.platform }}
|
name: Headless CLI ${{ matrix.platform }}
|
||||||
needs: publish
|
needs: publish
|
||||||
@@ -113,7 +142,7 @@ jobs:
|
|||||||
IFS=',' read -ra targets <<< "${{ matrix.rust-targets }}"
|
IFS=',' read -ra targets <<< "${{ matrix.rust-targets }}"
|
||||||
binaries=()
|
binaries=()
|
||||||
for target in "${targets[@]}"; do
|
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"
|
--bin tglock-cli --target "$target"
|
||||||
binaries+=("target/$target/release/tglock-cli")
|
binaries+=("target/$target/release/tglock-cli")
|
||||||
done
|
done
|
||||||
@@ -129,7 +158,7 @@ jobs:
|
|||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --locked --no-default-features \
|
cargo build --release --locked --no-default-features --features cli \
|
||||||
--bin tglock-cli --target ${{ matrix.rust-targets }}
|
--bin tglock-cli --target ${{ matrix.rust-targets }}
|
||||||
cp "target/${{ matrix.rust-targets }}/release/tglock-cli.exe" "${{ matrix.asset }}"
|
cp "target/${{ matrix.rust-targets }}/release/tglock-cli.exe" "${{ matrix.asset }}"
|
||||||
./"${{ matrix.asset }}" --version
|
./"${{ matrix.asset }}" --version
|
||||||
@@ -140,5 +169,5 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
prerelease: true
|
prerelease: false
|
||||||
files: ${{ matrix.asset }}
|
files: ${{ matrix.asset }}
|
||||||
|
|||||||
Generated
+1
-1
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.2"
|
version = "2.0.0-beta.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cipher",
|
"cipher",
|
||||||
|
|||||||
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.2"
|
version = "2.0.0-beta.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
description = "Telegram unblock via local WebSocket tunnel"
|
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
|
# 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.
|
# machines without a GPU or monitor able to build and run TGLock at all.
|
||||||
gui = ["dep:tauri", "dep:tauri-build", "dep:open"]
|
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]
|
[lib]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
@@ -26,11 +34,12 @@ required-features = ["gui"]
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "tglock-cli"
|
name = "tglock-cli"
|
||||||
path = "src/bin/cli.rs"
|
path = "src/bin/cli.rs"
|
||||||
|
required-features = ["cli"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [], optional = true }
|
tauri = { version = "2", features = [], optional = true }
|
||||||
open = { version = "5", 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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
|
|||||||
@@ -80,22 +80,54 @@ TGLock — это **локальный прокси** на твоём компь
|
|||||||
|
|
||||||
**[👉 Последний релиз](https://github.com/by-sonic/tglock/releases/latest)**
|
**[👉 Последний релиз](https://github.com/by-sonic/tglock/releases/latest)**
|
||||||
|
|
||||||
| Платформа | Файл | Размер |
|
| Платформа | Файл |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| **Windows 10/11** (x64) | `_x64-setup.exe` | ~2 МБ |
|
| **Windows 10/11** (x64) | `_x64-setup.exe` |
|
||||||
| **macOS** (Apple Silicon + Intel) | universal `.dmg` | ~7 МБ |
|
| **macOS** (Apple Silicon + Intel) | universal `.dmg` |
|
||||||
| **Linux** (x86_64) | `.deb` | ~3 МБ |
|
| **Linux** (x86_64) | `.deb` |
|
||||||
| **Linux** (x86_64, портативно) | `.AppImage` | ~79 МБ |
|
| **Linux** (x86_64, портативно) | `.AppImage` |
|
||||||
|
| **Сервер, контейнер, машина без монитора** | `tglock-cli-*` |
|
||||||
|
|
||||||
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно просто не создаётся: сервер, контейнер, виртуалка, машина без монитора или без 3D-ускорения.
|
Все сборки весят единицы мегабайт. Исключение — `.AppImage`: он несёт своё окружение и поэтому крупный.
|
||||||
>
|
|
||||||
> Он есть в `main` и собирается одной командой, а в готовые сборки релиза попадёт начиная со следующего тега:
|
> **🖥 `tglock-cli`** — тот же туннель без графического интерфейса, одним бинарём. Нужен там, где окно не создаётся: сервер, контейнер, виртуалка, машина без монитора. Доступен начиная с `v2.0.0-beta.2`. Если ты скачал beta.2 и приложение не открывалось — это была ошибка сборки, исправлено в beta.3. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
|
||||||
>
|
|
||||||
> ```bash
|
### 🛡 Антивирус ругается, SmartScreen предупреждает, VirusTotal показывает детекты
|
||||||
> cargo build --release --locked --no-default-features --bin tglock-cli
|
|
||||||
> ```
|
Так и будет. Объясню механизм и дам способы проверить, не доверяя мне на слово.
|
||||||
>
|
|
||||||
> Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
|
**Что ты увидишь.** На 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, при первом запуске может понадобиться:
|
> **🍎 macOS:** пока сборка не нотарифицирована Apple, при первом запуске может понадобиться:
|
||||||
> ```bash
|
> ```bash
|
||||||
@@ -137,7 +169,7 @@ Telegram → Настройки → **Продвинутые** → Тип сое
|
|||||||
tglock-cli # 127.0.0.1:1080, только для этого компьютера
|
tglock-cli # 127.0.0.1:1080, только для этого компьютера
|
||||||
tglock-cli --lan # 0.0.0.0:1080, только адреса Telegram
|
tglock-cli --lan # 0.0.0.0:1080, только адреса Telegram
|
||||||
tglock-cli --bind 10.0.0.5 --port 1443 # свой адрес и порт
|
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 # все флаги
|
tglock-cli --help # все флаги
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -188,7 +220,7 @@ journalctl -u tglock -f
|
|||||||
FROM rust:1.88 AS build
|
FROM rust:1.88 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
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
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||||
@@ -233,7 +265,7 @@ Telegram Desktop / mobile (через LAN)
|
|||||||
|
|
||||||
> Интерфейс различает три состояния и не выдаёт одно за другое: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«Telegram на связи»** — есть установленный туннель, то есть WebSocket-рукопожатие уже прошло. Смешивание первого и третьего состояния и было основной причиной жалоб «прокси подключён, а Telegram не работает».
|
> Интерфейс различает три состояния и не выдаёт одно за другое: **«Защита включена»** — локальный порт открыт, туннеля пока нет; **«Ищем новый маршрут»** — попытки были неудачными, идёт перебор; **«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 к нему подготовлен настолько, насколько может.
|
Это реальный риск, и 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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -399,7 +435,7 @@ npm run tauri build
|
|||||||
### Только CLI, без графики
|
### Только CLI, без графики
|
||||||
|
|
||||||
```bash
|
```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 собирается на голом сервере.
|
Ни Node.js, ни фронтенда, ни `libwebkit2gtk` для этого не нужно — при выключенной фиче `gui` Tauri и системный WebView в сборку не попадают вообще. Именно так CLI собирается на голом сервере.
|
||||||
@@ -409,9 +445,9 @@ cargo build --release --locked --no-default-features --bin tglock-cli
|
|||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo clippy --all-targets -- -D warnings
|
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 --all-targets
|
||||||
cargo test --no-default-features --lib --bins
|
cargo test --no-default-features --features cli --lib --bins
|
||||||
```
|
```
|
||||||
|
|
||||||
Тестов 59: разбор `obfuscated2`, каскад маршрутов и его cooldown, протокольные отказы SOCKS5, устойчивость секрета к перезапуску, плюс сквозной тест туннеля против мок-сервера, который реализует сторону Telegram и проверяет, что до неё доходит ровно тот открытый текст, который отправил клиент. Единственный тест с пометкой `#[ignore]` — тот, что требует живой сети.
|
Тестов 59: разбор `obfuscated2`, каскад маршрутов и его cooldown, протокольные отказы SOCKS5, устойчивость секрета к перезапуску, плюс сквозной тест туннеля против мок-сервера, который реализует сторону Telegram и проверяет, что до неё доходит ровно тот открытый текст, который отправил клиент. Единственный тест с пометкой `#[ignore]` — тот, что требует живой сети.
|
||||||
|
|||||||
@@ -118,10 +118,14 @@ Worker должен принимать WebSocket на:
|
|||||||
заданные оператором значения не перезаписываются, а `TGLOCK_FORCE_GPU=1`
|
заданные оператором значения не перезаписываются, а `TGLOCK_FORCE_GPU=1`
|
||||||
возвращает аппаратное ускорение. Для машин без монитора остаётся
|
возвращает аппаратное ускорение. Для машин без монитора остаётся
|
||||||
`tglock-cli`, которому WebView не нужен вовсе (issues #10, #17).
|
`tglock-cli`, которому WebView не нужен вовсе (issues #10, #17).
|
||||||
- Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована. Часть
|
- Windows-инсталлятор не подписан, macOS-сборка не нотарифицирована, и
|
||||||
антивирусов реагирует на неподписанный установщик, открывающий локальный
|
подписывать их не планируется: сертификат — ежегодный платёж, а проект
|
||||||
сокет; проверяемый ответ — сборка из исходников либо публичные логи
|
бесплатный. Часть антивирусов будет реагировать на неподписанный установщик,
|
||||||
GitHub Actions.
|
который открывает локальный сокет и прописывается прокси-сервером — это тот
|
||||||
|
же профиль, по которому ищут прокси-трояны. Вместо доверия предлагаются
|
||||||
|
проверяемые пути: `sha256` каждого артефакта публикуется GitHub на странице
|
||||||
|
релиза, сборка идёт в GitHub Actions из публичного коммита с открытым логом,
|
||||||
|
CLI собирается одной командой. Подробно — в разделе README про антивирус.
|
||||||
- Работоспособность медиа зависит от конкретного DC аккаунта и доступности
|
- Работоспособность медиа зависит от конкретного DC аккаунта и доступности
|
||||||
Telegram/Cloudflare у провайдера.
|
Telegram/Cloudflare у провайдера.
|
||||||
- Пулы заранее открытых WebSocket-соединений будут добавлены после измерения,
|
- Пулы заранее открытых 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 и произвольный
|
> больше нет, и правил системный DNS. Взято разделение GUI/CLI и произвольный
|
||||||
> bind-адрес; DNS-менеджмент и проверка root отброшены как ненужные.
|
> bind-адрес; DNS-менеджмент и проверка root отброшены как ненужные.
|
||||||
> - #12 закрыт: относился к шрифту старого egui-интерфейса.
|
> - #12 закрыт: относился к шрифту старого egui-интерфейса.
|
||||||
> - #10 и #17 **оставлены открытыми осознанно.** Появился headless `tglock-cli`,
|
> - #10 и #17 закрыты выпуском `v2.0.0-beta.2`: GUI перед стартом просит у
|
||||||
> который на таких машинах работает, но сам GUI по-прежнему не создаёт окно
|
> WebView программный рендер. Проверить это на машине без 3D-ускорения
|
||||||
> без 3D-ускорения. Это обход, а не исправление.
|
> возможности не было, поэтому закрыто как «исправление выпущено», а не
|
||||||
> - #9 (Android) остаётся в backlog без сроков, #21 ждёт подтверждения на
|
> «исправлено» — репортерам предложено переоткрыть, если проблема осталась.
|
||||||
> пересобранной сборке macOS.
|
> Независимо от WebView работает `tglock-cli`.
|
||||||
|
> - #21 закрыт: репорт относился к сборке macOS, которой больше нет, в
|
||||||
|
> `v2.0.0-beta.2` она пересобрана универсальным `.dmg`.
|
||||||
|
> - #9 (Android) остаётся единственным открытым — backlog без сроков.
|
||||||
|
>
|
||||||
|
> Претензия из публичного обсуждения, которую нельзя закрыть кодом: инсталлятор
|
||||||
|
> не подписан, из-за чего часть антивирусов на него реагирует. Решение принято
|
||||||
|
> и зафиксировано: подписи не будет, сертификат — ежегодный платёж, а проект
|
||||||
|
> бесплатный. Вместо неё в README описан механизм срабатывания и три
|
||||||
|
> проверяемых пути — сверка `sha256` с публикуемым GitHub digest, открытый лог
|
||||||
|
> сборки в Actions и сборка из исходников одной командой.
|
||||||
>
|
>
|
||||||
> Дополнительно исправлено то, чего в issues не было: коллизия MTProto-init с
|
> Дополнительно исправлено то, чего в issues не было: коллизия MTProto-init с
|
||||||
> байтом `0x05` (одно соединение из 256 уходило в SOCKS5-ветку и умирало),
|
> байтом `0x05` (одно соединение из 256 уходило в SOCKS5-ветку и умирало),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0-beta.2",
|
"version": "2.0.0-beta.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 1420",
|
"dev": "vite --port 1420",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Проверяет, что в бандл приложения попал GUI-бинарь, а не headless CLI.
|
||||||
|
|
||||||
|
Зачем это существует. В v2.0.0-beta.2 и v2.0.0-beta.3 бандлер Tauri упаковал
|
||||||
|
`tglock-cli` как исполняемый файл приложения, и оно не запускалось ни на одной
|
||||||
|
платформе. В beta.3 имя файла было уже правильным — бандлер переименовал CLI —
|
||||||
|
поэтому проверка имени или `CFBundleExecutable` ничего не заметила. Отличить
|
||||||
|
можно только по содержимому.
|
||||||
|
|
||||||
|
Почему на Python, а не grep. BSD grep на macOS в UTF-8-локали молча не находит
|
||||||
|
строки в бинарных данных там, где GNU grep находит: проверка проходила на
|
||||||
|
Linux и давала ложное «не GUI» на macOS. Один скрипт для всех платформ и для
|
||||||
|
всех трёх мест, где проверка вызывается, исключает подобные расхождения.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python3 scripts/verify_bundle_binary.py <путь-к-бинарю>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Присутствуют только в GUI: строка CSP из tauri.conf.json и имя движка WebView.
|
||||||
|
GUI_MARKERS = (b"ipc.localhost", b"wry")
|
||||||
|
# Присутствуют только в CLI: имена флагов clap.
|
||||||
|
CLI_MARKERS = (b"allow-direct", b"secret-file")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) != 2:
|
||||||
|
print(f"использование: {argv[0]} <путь-к-бинарю>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
path = argv[1]
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
data = handle.read()
|
||||||
|
except OSError as error:
|
||||||
|
print(f"не удалось прочитать {path}: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
found_gui = [marker.decode() for marker in GUI_MARKERS if marker in data]
|
||||||
|
found_cli = [marker.decode() for marker in CLI_MARKERS if marker in data]
|
||||||
|
|
||||||
|
print(f"файл: {path}")
|
||||||
|
print(f"размер: {len(data) / 1048576:.1f} МБ")
|
||||||
|
print(f"GUI: {found_gui or 'признаков нет'}")
|
||||||
|
print(f"CLI: {found_cli or 'признаков нет'}")
|
||||||
|
|
||||||
|
if found_cli:
|
||||||
|
print("ОШИБКА: в бандле headless CLI вместо приложения", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not found_gui:
|
||||||
|
print("ОШИБКА: это не GUI-бинарь, признаков GUI нет", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("OK: в бандле GUI-бинарь")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
+71
-10
@@ -561,31 +561,38 @@ mod tests {
|
|||||||
/// Stand-in for `kwsN.web.telegram.org`: a plaintext WebSocket that behaves
|
/// Stand-in for `kwsN.web.telegram.org`: a plaintext WebSocket that behaves
|
||||||
/// like an obfuscated2 relay.
|
/// like an obfuscated2 relay.
|
||||||
///
|
///
|
||||||
/// Returns the raw init frame it was handed and the plaintext it recovered,
|
/// Returns the URI it was asked for, the raw init frame it was handed and
|
||||||
/// so a test can assert on what Telegram would really have seen.
|
/// 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`,
|
// The handshake callback's error type is tungstenite's own `ErrorResponse`,
|
||||||
// whose size is not ours to change.
|
// whose size is not ours to change.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
async fn mock_relay(
|
async fn mock_relay(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
response: Vec<u8>,
|
response: Vec<u8>,
|
||||||
) -> Result<(Vec<u8>, Vec<u8>), String> {
|
) -> Result<(String, Vec<u8>, Vec<u8>), String> {
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
|
||||||
let (tcp, _) = listener.accept().await.map_err(|e| e.to_string())?;
|
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
|
// Telegram confirms the `binary` subprotocol the proxy asks for, and
|
||||||
// tungstenite refuses a handshake that silently drops it. A mock that
|
// tungstenite refuses a handshake that silently drops it. A mock that
|
||||||
// does not answer it would only ever test the failure path.
|
// does not answer it would only ever test the failure path.
|
||||||
let mut websocket =
|
let mut websocket = tokio_tungstenite::accept_hdr_async(
|
||||||
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
|
tcp,
|
||||||
|
move |request: &Request, mut response: Response| {
|
||||||
|
*seen.lock().unwrap() = request.uri().to_string();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
"Sec-WebSocket-Protocol",
|
"Sec-WebSocket-Protocol",
|
||||||
"binary".parse().expect("static header value"),
|
"binary".parse().expect("static header value"),
|
||||||
);
|
);
|
||||||
Ok(response)
|
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 {
|
let init = match websocket.next().await {
|
||||||
Some(Ok(Message::Binary(data))) => data,
|
Some(Ok(Message::Binary(data))) => data,
|
||||||
@@ -615,7 +622,7 @@ mod tests {
|
|||||||
.send(Message::Binary(wire))
|
.send(Message::Binary(wire))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok((init, request))
|
Ok((requested, init, request))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -896,7 +903,7 @@ mod tests {
|
|||||||
"the client must see Telegram's plaintext"
|
"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_eq!(init_frame.len(), INIT_LEN);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
init_frame.as_slice(),
|
init_frame.as_slice(),
|
||||||
@@ -919,6 +926,60 @@ mod tests {
|
|||||||
let _ = server.await.unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn a_tunnel_counts_only_after_the_handshake_succeeds() {
|
async fn a_tunnel_counts_only_after_the_handshake_succeeds() {
|
||||||
// Accepts TCP and then stays silent, so the WebSocket handshake never
|
// 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
|
/// Point every data centre at a local plaintext WebSocket server so the
|
||||||
/// whole tunnel can be exercised without reaching Telegram.
|
/// whole tunnel can be exercised without reaching Telegram.
|
||||||
pub(crate) fn force_local_route(&self, port: u16) {
|
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 {
|
*self.forced_routes.lock().unwrap() = vec![Route {
|
||||||
connect_host: "127.0.0.1".to_owned(),
|
connect_host: "127.0.0.1".to_owned(),
|
||||||
websocket_host: format!("127.0.0.1:{}", port),
|
websocket_host: format!("127.0.0.1:{}", port),
|
||||||
path: "/apiws".to_owned(),
|
path,
|
||||||
kind: RouteKind::TelegramIp,
|
kind,
|
||||||
port,
|
port,
|
||||||
secure: false,
|
secure: false,
|
||||||
}];
|
}];
|
||||||
@@ -233,14 +237,15 @@ impl TransportEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut routes = routes_for_dc(key.dc, key.media);
|
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;
|
return routes;
|
||||||
};
|
};
|
||||||
for domain in self.worker_domains.lock().unwrap().iter() {
|
for domain in self.worker_domains.lock().unwrap().iter() {
|
||||||
|
let path = path.clone();
|
||||||
routes.push(Route::https(
|
routes.push(Route::https(
|
||||||
domain.clone(),
|
domain.clone(),
|
||||||
domain.clone(),
|
domain.clone(),
|
||||||
format!("/apiws?dst={}&dc={}", destination, key.dc),
|
path,
|
||||||
RouteKind::CloudflareWorker,
|
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 {
|
fn canonical_dc(dc: u16) -> u16 {
|
||||||
if dc == 203 {
|
if dc == 203 {
|
||||||
2
|
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]
|
#[test]
|
||||||
fn route_codes_and_labels_round_trip() {
|
fn route_codes_and_labels_round_trip() {
|
||||||
for kind in [
|
for kind in [
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "TGLock",
|
"productName": "TGLock",
|
||||||
"version": "2.0.0-beta.2",
|
"version": "2.0.0-beta.5",
|
||||||
"identifier": "com.bysonic.tglock",
|
"identifier": "com.bysonic.tglock",
|
||||||
|
"mainBinaryName": "tglock",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"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