docs: инструкция по Cloudflare Worker + скрипт, снять флаг пререлиза (#29)

Резервный маршрут через Worker был в коде с 2.0, но воспользоваться им никто
не мог: в ARCHITECTURE_V2.md описан только контракт эндпоинта — это
спецификация для того, кто будет писать воркер, а не руководство. Ни скрипта,
ни шагов в репозитории не было. Поэтому люди, у которых легли все обычные
маршруты, писали «не работает» вместо того, чтобы включить запасной выход.

Добавлено:
- worker/tglock-worker.js — готовый скрипт. Проверяет путь и upgrade,
  подтверждает подпротокол binary (без этого клиент рвёт рукопожатие),
  соединяется только с семью адресами Telegram, которые запрашивает TGLock,
  и поддерживает необязательный TGLOCK_TOKEN. Без списка адресов воркер стал
  бы открытым TCP-прокси для любого, кто узнает его адрес.
- docs/CLOUDFLARE_WORKER.md — когда это нужно и когда нет (таблица
  «что видно в приложении → нужен ли Worker»), установка через веб-интерфейс,
  проверка живости, подключение в GUI и через --worker, ограничение доступа,
  контракт для своих реализаций.
- Ссылки из README: в FAQ про блокировку web.telegram.org и в блок docs.

Контракт закреплён тестами, чтобы документация не разошлась с кодом:
- worker_path вынесен в функцию, из неё же строятся боевые маршруты;
- documented_worker_contract_matches_the_requested_path сверяет формат пути;
- worker_allowlist_covers_every_address_a_route_can_ask_for падает, если в
  маршрутах появится адрес, которого нет в скрипте воркера;
- connects_through_the_documented_worker_contract поднимает сервер, ведущий
  себя ровно по документации, и проверяет что туннель работает в обе стороны
  и что запрошен именно документированный URI.

Чего тесты не проверяют: развёрнутый воркер в самом Cloudflare. Это указано и
в самой инструкции.

Отдельно: снят флаг prerelease в release.yml. До правки /releases/latest
отдавал v2.0.0-beta.1, то есть кнопка «Скачать» в README вела на сборку без
CLI и без фикса рендера. Существующий релиз v2.0.0-beta.2 помечен как latest
вручную.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
This commit is contained in:
Никита Sonic
2026-07-30 14:06:19 +03:00
committed by GitHub
parent b06272437c
commit f03e9106ee
7 changed files with 357 additions and 40 deletions
+2 -2
View File
@@ -72,7 +72,7 @@ jobs:
- Linux: скачайте `.AppImage` или `.deb`
- Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса
releaseDraft: false
prerelease: true
prerelease: false
args: ${{ matrix.args }}
cli:
@@ -140,5 +140,5 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
prerelease: true
prerelease: false
files: ${{ matrix.asset }}
+18 -19
View File
@@ -80,22 +80,17 @@ 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`. Подробности — [ниже](#-без-графического-интерфейса-tglock-cli).
> **🍎 macOS:** пока сборка не нотарифицирована Apple, при первом запуске может понадобиться:
> ```bash
@@ -137,7 +132,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 # все флаги
```
@@ -233,7 +228,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 +339,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>
+86
View File
@@ -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), поправлю.
+12 -5
View File
@@ -14,11 +14,18 @@
> больше нет, и правил системный 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 без сроков.
>
> Единственная претензия из публичного обсуждения, которую нельзя закрыть
> кодом: инсталлятор не подписан, из-за чего часть антивирусов на него
> реагирует. Требует покупки сертификата.
>
> Дополнительно исправлено то, чего в issues не было: коллизия MTProto-init с
> байтом `0x05` (одно соединение из 256 уходило в SOCKS5-ветку и умирало),
+71 -10
View File
@@ -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
View File
@@ -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 [
+104
View File
@@ -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" },
});
},
};