fix(diag): причина отказа туннеля и судьба домена Worker'а попадали в никуда (#50) (#54)

`TransportEngine::connect` собирает подробный перечень попыток — какой адрес не
ответил, где истёк TLS, что вернул воркер, — и возвращает его в `Err`. Дальше
этот `Err` доходил до `serve`, где выбрасывался: `let _ = handle(...)`.
Увеличивался только счётчик.

Снаружи это выглядит как `туннелей 0 · сбоев 249 · падений маршрутов 395` без
единого слова о том, почему их ноль. Отличить «провайдер режет закреплённые
адреса» от «воркер отвечает отказом» нечем, хотя рядом есть журнал событий, в
который пишутся куда менее важные вещи.

Теперь причина попадает в журнал строкой вида:

    Не поднялся туннель до DC2: 149.154.167.51 — не отвечает (таймаут TCP);
    kws2.web.telegram.org — таймаут TLS/WebSocket

Дедупликация журнала делает её разовой: набор маршрутов у DC стабилен.

Там же вторая слепая зона. Домен воркера, не похожий на имя хоста, отбрасывался
молча: `https://name.workers.dev/` со схемой или слэшем не проходит
`valid_domain`, маршрут не появляется, и «воркер настроен» неотличимо от
«воркера нет». `set_worker_domains` теперь возвращает принятые и отвергнутые
по отдельности, отвергнутые называются вместе с причиной, принятые
подтверждаются.

Тексты отказов переведены на русский: их читает не разработчик, а человек,
который прислал скриншот и ждёт ответа.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
This commit is contained in:
Никита Sonic
2026-08-26 16:59:13 +03:00
committed by GitHub
parent 28085f9127
commit fe9aad5ee9
3 changed files with 167 additions and 20 deletions
+85 -2
View File
@@ -217,7 +217,18 @@ impl Stats {
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
self.transport.set_worker_domains(&domains);
let result = self.transport.set_worker_domains(&domains);
// Молчание здесь неотличимо от «воркер работает»: пока строка не
// попадала в список маршрутов, об этом не сообщалось ничем, и человек
// считал резервный маршрут настроенным (by-sonic/tglock#50).
for rejected in &result.rejected {
self.note(format!(
"Cloudflare Worker «{rejected}» не похож на имя хоста — маршрут не добавлен. Нужно только имя, без https:// и без косой черты: example.workers.dev"
));
}
for accepted in &result.accepted {
self.note(format!("Cloudflare Worker в списке маршрутов: {accepted}"));
}
}
pub fn stop(&self) {
@@ -665,7 +676,17 @@ async fn ws_tunnel(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use futures_util::{SinkExt, StreamExt};
let (ws, connected) = stats.transport.connect(dc, media).await?;
let (ws, connected) = match stats.transport.connect(dc, media).await {
Ok(connected) => connected,
Err(error) => {
// Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше
// текст уходил в `Err` и там пропадал: оставался счётчик сбоев без
// причины, и отличить «провайдер режет адреса» от «воркер отвечает
// отказом» было нечем (by-sonic/tglock#50).
stats.note(error.clone());
return Err(error.into());
}
};
let _tunnel = EstablishedTunnel::new(stats);
stats.note_tunnel(dc, connected.route.kind.ui_code());
@@ -1473,6 +1494,68 @@ mod tests {
let _ = server.await.unwrap();
}
/// Диагностика обязана называть причину, а не только считать сбои.
///
/// При `туннелей 0` счётчик сбоев говорит, что не получилось, и молчит о
/// том, почему. Текст ошибки собирался и выбрасывался, и разобрать
/// «провайдер режет адреса» против «воркер отвечает отказом» было нечем
/// (by-sonic/tglock#50).
#[tokio::test]
async fn a_cascade_that_failed_says_why_in_the_log() {
let dead = TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead_port = dead.local_addr().unwrap().port();
drop(dead);
let stats = Stats::new();
stats.transport.force_local_route(dead_port);
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, 2);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client.write_all(&init).await.unwrap();
wait_until("сбой засчитан", || {
stats.ws_failures.load(Ordering::Relaxed) > 0
})
.await;
let events = stats.drain_events();
let named = events
.iter()
.find(|event| event.contains("Не поднялся туннель до DC2"))
.unwrap_or_else(|| panic!("причина отказа не попала в журнал: {events:?}"));
assert!(
named.contains("127.0.0.1"),
"в журнале должен быть назван адрес, до которого не дошли: {named}"
);
stats.stop();
let _ = server.await.unwrap();
}
/// Строка, не похожая на имя хоста, отбрасывалась молча, и «воркер
/// настроен» ничем не отличалось от «воркера нет» (by-sonic/tglock#50).
#[test]
fn a_worker_domain_is_confirmed_or_named_as_rejected() {
let stats = Stats::new();
stats.set_worker_domain("https://mine.workers.dev/, spare.workers.dev");
let events = stats.drain_events();
assert!(
events
.iter()
.any(|event| event.contains("https://mine.workers.dev/")
&& event.contains("не похож на имя хоста")),
"отвергнутый домен должен быть назван вместе с причиной: {events:?}"
);
assert!(
events
.iter()
.any(|event| event.contains("в списке маршрутов: spare.workers.dev")),
"принятый домен нужно подтвердить, иначе проверить нечем: {events:?}"
);
}
#[tokio::test]
#[ignore = "requires live Telegram network access"]
async fn accepts_mtproto_and_builds_live_media_tunnel() {