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
+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 [