diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9169885..7ba250f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index a77c23f..e6ae63a 100644 --- a/README.md +++ b/README.md @@ -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`, и никакой запас маршрутов этого не отменяет.
diff --git a/docs/CLOUDFLARE_WORKER.md b/docs/CLOUDFLARE_WORKER.md new file mode 100644 index 0000000..50629b8 --- /dev/null +++ b/docs/CLOUDFLARE_WORKER.md @@ -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=&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), поправлю. diff --git a/docs/ISSUE_AUDIT.md b/docs/ISSUE_AUDIT.md index c98cf04..f5be903 100644 --- a/docs/ISSUE_AUDIT.md +++ b/docs/ISSUE_AUDIT.md @@ -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-ветку и умирало), diff --git a/src/proxy.rs b/src/proxy.rs index 9497273..8eb166a 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -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, - ) -> Result<(Vec, Vec), String> { + ) -> Result<(String, Vec, Vec), 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 diff --git a/src/transport.rs b/src/transport.rs index a3cb5cf..481581e 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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 { + 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 [ diff --git a/worker/tglock-worker.js b/worker/tglock-worker.js new file mode 100644 index 0000000..bcd6df0 --- /dev/null +++ b/worker/tglock-worker.js @@ -0,0 +1,104 @@ +// Резервный маршрут TGLock через Cloudflare Worker. +// +// Нужен в одном случае: провайдер заблокировал саму веб-инфраструктуру +// Telegram, и все обычные маршруты TGLock перестали отвечать. Тогда соединение +// идёт на твой домен *.workers.dev, а воркер доводит его до Telegram. +// +// Инструкция по установке: docs/CLOUDFLARE_WORKER.md +// +// Контракт, который ожидает клиент (src/transport.rs): +// wss://<домен>/apiws?dst=&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" }, + }); + }, +};