fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)

* fix: address routing, media, worker and platform issues

* ci: use available Android tools and verify Windows CLI artifact

* fix(gui): return a result from asynchronous stop command

* test(android): verify installed APK and proxy lifecycle on emulator

* fix(media): use native MTProto for CDN203 and retain recent diagnostics

* Fix owned WebSocket split after transport boxing

* Clarify CDN transport and connection status guarantees

* Recognize accessible Android power button labels

* chore(release): prepare 2.0.0-beta.15 with verified draft publication

---------

Co-authored-by: babin <Thartewerner536e@engineer.com>
This commit is contained in:
Никита Sonic
2026-09-19 16:04:39 +03:00
committed by GitHub
parent 8617d25f3a
commit 05ce9566a8
78 changed files with 4752 additions and 765 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tglock::cli_settings as config;
use tglock::{proxy, transport};
use tglock_lib::cli_settings as config;
use tglock_lib::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1);
+519
View File
@@ -0,0 +1,519 @@
use crate::config::ListenConfig;
use crate::{proxy, transport};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
use tauri_plugin_opener::OpenerExt;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl AppState {
fn new(settings_path: PathBuf, stats: Arc<proxy::Stats>) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats,
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
task: tokio::sync::Mutex::new(None),
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
async fn start_proxy(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<StatusSnapshot, String> {
// Serialize transitions, including shutdown, so a rapid double click cannot
// create two listeners or stop a newly started Android service.
let mut task = state.task.lock().await;
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
if let Some(previous) = task.take() {
let _ = previous.await;
}
let settings = state.settings.lock().unwrap().clone();
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
let listener = proxy::bind(listen).await?;
// Called while the user's Activity is visible, before opening Telegram.
// A rejected foreground-service start must not leave a hidden proxy running.
set_foreground_service(&app, true)?;
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.active_listen.lock().unwrap() = Some(listen);
*state.started_at.lock().unwrap() = Some(Instant::now());
let stats = state.stats.clone();
let logs = state.logs.clone();
let worker_app = app.clone();
let worker = tokio::spawn(async move {
if let Err(error) = proxy::serve(stats.clone(), listener, listen.allow_direct).await {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
stats.running.store(false, Ordering::SeqCst);
if let Err(error) = set_foreground_service(&worker_app, false) {
push_log(&logs, error, true);
}
});
while !state.stats.running.load(Ordering::SeqCst) && !worker.is_finished() {
tokio::task::yield_now().await;
}
*task = Some(worker);
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err("Не удалось запустить прокси; подробности в диагностике".into());
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
if let Err(error) = open_telegram_link(&app, &state) {
// The working proxy remains available through the copy-link control.
state.log(error, true);
}
Ok(state.snapshot())
}
#[tauri::command]
async fn stop_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
let mut task = state.task.lock().await;
state.stats.stop();
if let Some(worker) = task.take() {
let _ = worker.await;
}
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
Ok(state.snapshot())
}
fn telegram_link(state: &AppState) -> Result<String, String> {
if !state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала включите защиту".into());
}
state
.active_listen
.lock()
.unwrap()
.map(|listen| listen.telegram_link(&state.stats.telegram_secret()))
.ok_or_else(|| "Сначала включите защиту".into())
}
#[tauri::command]
fn get_telegram_link(state: State<'_, AppState>) -> Result<String, String> {
telegram_link(&state)
}
fn open_telegram_link(app: &tauri::AppHandle, state: &AppState) -> Result<(), String> {
app.opener()
.open_url(telegram_link(state)?, None::<&str>)
.map_err(|error| {
format!("Не удалось открыть Telegram: {error}. Скопируйте ссылку подключения.")
})
}
#[tauri::command]
async fn open_telegram(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
open_telegram_link(&app, &state)
}
#[cfg(target_os = "android")]
struct TunnelPlugin(tauri::plugin::PluginHandle<tauri::Wry>);
#[cfg(target_os = "android")]
fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> {
app.state::<TunnelPlugin>()
.0
.run_mobile_plugin::<serde_json::Value>(
"setEnabled",
serde_json::json!({ "enabled": enabled }),
)
.map(|_| ())
.map_err(|error| format!("Фоновый сервис Android: {error}"))
}
#[cfg(not(target_os = "android"))]
fn set_foreground_service(_app: &tauri::AppHandle, _enabled: bool) -> Result<(), String> {
Ok(())
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
prefer_software_rendering();
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("tunnel")
.setup(|app, api| {
let handle = api.register_android_plugin("com.bysonic.tglock", "TunnelPlugin")?;
app.manage(TunnelPlugin(handle));
Ok(())
})
.build(),
);
builder
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let secret_path = settings_path.with_file_name("secret");
let stored = crate::mtproto::load_or_create_secret_at_with_migration(
&secret_path,
crate::mtproto::legacy_secret_path().as_deref(),
);
let state = AppState::new(settings_path, proxy::Stats::with_stored_secret(stored));
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не записан ({error}). Проверьте ссылку после перезапуска"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy,
get_telegram_link,
open_telegram
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(any(
target_os = "macos",
target_os = "android",
target_os = "ios"
)) {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
//! TGLock core: the MTProto/WebSocket transport shared by the desktop GUI and
//! the headless CLI.
//!
//! Nothing in this crate depends on Tauri or on a windowing system, so the
//! With the `gui` feature disabled this crate does not depend on Tauri, so the
//! `tglock-cli` binary can be built with `--no-default-features` on a server
//! that has neither a GPU nor a monitor.
@@ -14,3 +14,7 @@ pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")]
pub mod cli_settings;
/// Desktop and Android graphical application; absent from headless builds.
#[cfg(feature = "gui")]
pub mod gui;
+1 -431
View File
@@ -1,435 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
use tglock::config::ListenConfig;
use tglock::{proxy, transport};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
lan_mode: bool,
port: u16,
worker_domain: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
lan_mode: false,
port: proxy::DEFAULT_PORT,
worker_domain: String::new(),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LogLine {
timestamp: String,
message: String,
error: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusSnapshot {
running: bool,
active_connections: u32,
tunnels: u32,
data_center: Option<u16>,
route: String,
failures: u32,
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
/// состоялось через запасной адрес (by-sonic/tglock#32).
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
/// (by-sonic/tglock#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
unknown_clients: u32,
/// Соединения, которые открылись и ничего не прислали до таймаута.
///
/// Растущее число в такт с переподключениями клиента означает, что он
/// открывает соединения впрок, а мы закрываем их по таймауту
/// (by-sonic/tglock#42).
silent_clients: u32,
uptime_seconds: u64,
port: u16,
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
/// телефоне или в эмуляторе означает само устройство, и подключение не
/// работало (by-sonic/tglock#36).
share_address: Option<String>,
logs: Vec<LogLine>,
}
struct AppState {
stats: Arc<proxy::Stats>,
settings: Mutex<Settings>,
active_port: Mutex<u16>,
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
/// сейчас выбран в настройках.
active_listen: Mutex<Option<ListenConfig>>,
started_at: Mutex<Option<Instant>>,
logs: Arc<Mutex<Vec<LogLine>>>,
settings_path: PathBuf,
}
impl AppState {
fn new(settings_path: PathBuf) -> Self {
let settings = std::fs::read(&settings_path)
.ok()
.and_then(|contents| serde_json::from_slice(&contents).ok())
.unwrap_or_default();
Self {
stats: proxy::Stats::new(),
settings: Mutex::new(settings),
active_port: Mutex::new(proxy::DEFAULT_PORT),
active_listen: Mutex::new(None),
started_at: Mutex::new(None),
logs: Arc::new(Mutex::new(Vec::new())),
settings_path,
}
}
fn log(&self, message: impl Into<String>, error: bool) {
let mut logs = self.logs.lock().unwrap();
logs.push(LogLine {
timestamp: current_time(),
message: message.into(),
error,
});
if logs.len() > 100 {
logs.remove(0);
}
}
fn snapshot(&self) -> StatusSnapshot {
// События прокси доходят до журнала только здесь: у ядра нет своего
// способа что-то показать, а интерфейс и так опрашивает состояние.
for event in self.stats.drain_events() {
self.log(event, false);
}
let data_center = self.stats.last_dc();
let route = transport::route_label(self.stats.last_route());
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
tunnels: self.stats.ws.load(Ordering::Relaxed),
data_center: (data_center > 0).then_some(data_center),
route: route.to_owned(),
failures: self.stats.ws_failures.load(Ordering::Relaxed),
route_failures: self.stats.route_failures(),
blocked: self.stats.blocked.load(Ordering::Relaxed),
unknown_clients: self.stats.unknown_clients.load(Ordering::Relaxed),
silent_clients: self.stats.silent_clients.load(Ordering::Relaxed),
uptime_seconds: self
.started_at
.lock()
.unwrap()
.map_or(0, |started| started.elapsed().as_secs()),
port: *self.active_port.lock().unwrap(),
share_address: share_address(*self.active_listen.lock().unwrap()),
logs: self.logs.lock().unwrap().clone(),
}
}
fn persist_settings(&self, settings: &Settings) -> Result<(), String> {
if let Some(parent) = self.settings_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Не удалось создать папку настроек: {error}"))?;
}
let contents = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))?;
std::fs::write(&self.settings_path, contents)
.map_err(|error| format!("Не удалось сохранить настройки: {error}"))
}
}
/// Адрес, который нужно вписать в Telegram на другом устройстве.
///
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
listen
.filter(|listen| listen.addr.ip().is_unspecified())
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
}
fn current_time() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}:{:02}",
(seconds / 3600) % 24,
(seconds / 60) % 60,
seconds % 60
)
}
#[tauri::command]
fn get_status(state: State<'_, AppState>) -> StatusSnapshot {
state.snapshot()
}
#[tauri::command]
fn get_settings(state: State<'_, AppState>) -> Settings {
state.settings.lock().unwrap().clone()
}
#[tauri::command]
fn save_settings(settings: Settings, state: State<'_, AppState>) -> Result<Settings, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Err("Сначала выключите защиту".into());
}
if settings.port == 0 {
return Err("Порт должен быть от 1 до 65535".into());
}
state.persist_settings(&settings)?;
*state.settings.lock().unwrap() = settings.clone();
state.log("Настройки сохранены", false);
Ok(settings)
}
#[tauri::command]
fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
if state.stats.running.load(Ordering::SeqCst) {
return Ok(state.snapshot());
}
let settings = state.settings.lock().unwrap().clone();
state.stats.set_worker_domain(&settings.worker_domain);
*state.active_port.lock().unwrap() = settings.port;
*state.started_at.lock().unwrap() = Some(Instant::now());
state.log("Запускаю защищённый маршрут…", false);
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
*state.active_listen.lock().unwrap() = Some(listen);
let stats = state.stats.clone();
let logs = state.logs.clone();
std::thread::spawn(move || {
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(error) => {
push_log(&logs, format!("Не удалось запустить сервис: {error}"), true);
return;
}
};
if let Err(error) = runtime.block_on(proxy::run(stats, listen)) {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
});
std::thread::sleep(std::time::Duration::from_millis(220));
if !state.stats.running.load(Ordering::SeqCst) {
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
return Err(state
.logs
.lock()
.unwrap()
.last()
.map(|line| line.message.clone())
.unwrap_or_else(|| "Не удалось запустить прокси".into()));
}
state.log(format!("Прокси запущен на {}", listen.addr), false);
let _ = open::that(listen.telegram_link(&state.stats.telegram_secret()));
state.log("Открываю подключение в Telegram…", false);
Ok(state.snapshot())
}
#[tauri::command]
fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot {
state.stats.stop();
*state.active_listen.lock().unwrap() = None;
*state.started_at.lock().unwrap() = None;
state.log("Защита выключена", false);
state.snapshot()
}
fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
logs.lock().unwrap().push(LogLine {
timestamp: current_time(),
message,
error,
});
}
/// Environment variables that make the WebView render without a GPU.
///
/// The window is never created when 3D acceleration is unavailable: no
/// monitor, the default Microsoft display driver, a virtual machine without
/// 3D enabled (by-sonic/tglock#10, by-sonic/tglock#17). For a small status
/// panel software rendering costs nothing noticeable, so preferring it is the
/// safer default.
///
/// Values already present in the environment are never overwritten, and
/// `TGLOCK_FORCE_GPU` disables the whole mechanism.
fn software_rendering_vars(
force_gpu: bool,
is_set: impl Fn(&str) -> bool,
) -> Vec<(&'static str, &'static str)> {
if force_gpu {
return Vec::new();
}
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--disable-gpu --disable-gpu-compositing",
)]
} else if cfg!(target_os = "macos") {
// WebKit on macOS falls back to software rendering on its own.
&[]
} else {
&[
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]
};
candidates
.iter()
.filter(|(key, _)| !is_set(key))
.copied()
.collect()
}
fn prefer_software_rendering() {
let force_gpu = std::env::var_os("TGLOCK_FORCE_GPU").is_some();
for (key, value) in software_rendering_vars(force_gpu, |key| std::env::var_os(key).is_some()) {
std::env::set_var(key, value);
}
}
fn main() {
prefer_software_rendering();
tauri::Builder::default()
.setup(|app| {
let settings_path = app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join("settings.json");
let state = AppState::new(settings_path);
// Если секрет не удалось записать, ссылка tg://proxy изменится после
// перезапуска и Telegram откажется подключаться к сохранённой.
// Раньше это происходило молча (by-sonic/tglock#37).
if let Some(error) = state.stats.secret_write_error() {
state.log(
format!("Секрет не сохранён ({error}). После перезапуска ссылка изменится"),
true,
);
}
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_status,
get_settings,
save_settings,
start_proxy,
stop_proxy
])
.run(tauri::generate_context!())
.expect("failed to run TGLock");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_to_share_when_the_proxy_is_off_or_local() {
assert_eq!(
share_address(None),
None,
"выключенный прокси нечего делить"
);
assert_eq!(
share_address(Some(ListenConfig::loopback(1080))),
None,
"на loopback снаружи никто не подключится"
);
}
#[test]
fn lan_mode_shares_a_reachable_address() {
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
assert!(
!shown.starts_with("0.0.0.0"),
"0.0.0.0 нельзя вписать в Telegram: {shown}"
);
assert!(
!shown.starts_with("127.0.0.1"),
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
);
}
#[test]
fn software_rendering_is_requested_by_default() {
let vars = software_rendering_vars(false, |_| false);
if cfg!(target_os = "macos") {
assert!(vars.is_empty(), "macOS needs no override");
} else {
assert!(
!vars.is_empty(),
"a machine without 3D acceleration must still get a window"
);
}
}
#[test]
fn force_gpu_disables_the_override() {
assert!(software_rendering_vars(true, |_| false).is_empty());
}
#[test]
fn an_operators_own_value_is_never_overwritten() {
assert!(software_rendering_vars(false, |_| true).is_empty());
}
#[test]
fn windows_uses_webview2_arguments_and_linux_uses_webkit_ones() {
let keys: Vec<_> = software_rendering_vars(false, |_| false)
.into_iter()
.map(|(key, _)| key)
.collect();
if cfg!(target_os = "windows") {
assert_eq!(keys, ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]);
} else if cfg!(target_os = "linux") {
assert_eq!(
keys,
[
"WEBKIT_DISABLE_COMPOSITING_MODE",
"WEBKIT_DISABLE_DMABUF_RENDERER"
]
);
}
}
tglock_lib::gui::run();
}
+120 -8
View File
@@ -2,9 +2,7 @@ use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher};
use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256};
use std::path::Path;
#[cfg(not(test))]
use std::path::PathBuf;
use std::path::{Path, PathBuf};
type AesCtr = ctr::Ctr128BE<Aes256>;
@@ -119,6 +117,12 @@ impl StoredSecret {
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
/// при каждом старте, отключает всех уже настроенных клиентов.
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
load_or_create_secret_at_with_migration(path, None)
}
/// Preserve desktop proxy links when the GUI moves to Tauri's app directory.
/// The legacy file remains intact, including when copying it fails.
pub fn load_or_create_secret_at_with_migration(path: &Path, legacy: Option<&Path>) -> StoredSecret {
if let Ok(value) = std::fs::read_to_string(path) {
if let Some(value) = parse_secret_hex(value.trim()) {
return StoredSecret {
@@ -128,7 +132,10 @@ pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
}
}
let value = generate_secret();
let value = legacy
.and_then(|legacy| std::fs::read_to_string(legacy).ok())
.and_then(|value| parse_secret_hex(value.trim()))
.unwrap_or_else(generate_secret);
let write_error = store_secret(path, &secret_hex(&value))
.err()
.map(|error| format!("{}: {error}", path.display()));
@@ -146,7 +153,7 @@ fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
#[cfg(not(test))]
pub fn load_or_create_secret() -> StoredSecret {
match secret_path() {
match legacy_secret_path() {
Some(path) => load_or_create_secret_at(&path),
None => StoredSecret {
value: generate_secret(),
@@ -155,8 +162,7 @@ pub fn load_or_create_secret() -> StoredSecret {
}
}
#[cfg(not(test))]
fn secret_path() -> Option<PathBuf> {
pub fn legacy_secret_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("APPDATA")
@@ -221,7 +227,7 @@ pub fn parse_secret(value: &str) -> Option<[u8; 16]> {
}
fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 {
if value.len() != 32 || !value.is_ascii() {
return None;
}
let mut secret = [0; 16];
@@ -300,6 +306,7 @@ fn is_reserved_init(init: &[u8; INIT_LEN]) -> bool {
|| &init[..4] == b"HEAD"
|| &init[..4] == b"POST"
|| &init[..4] == b"GET "
|| &init[..4] == b"OPTI"
|| init[..4] == [0xee; 4]
|| init[..4] == [0xdd; 4]
|| init[..4] == [0x16, 0x03, 0x01, 0x02]
@@ -682,11 +689,50 @@ mod tests {
"00112233445566778899aabbccddee", // 30 символов
"00112233445566778899aabbccddeeffff", // 34 символа
"zz112233445566778899aabbccddeeff", // не hex
"0я00000000000000000000000000000", // 32 bytes, UTF-8 boundary at byte 2
] {
assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут");
}
}
#[test]
fn android_obfuscated2_vector_survives_fragmented_translation() {
// Independently generated with Node/OpenSSL AES-256-CTR and SHA-256,
// following Telegram Android Connection.cpp sendData/encryptKeyWithSecret.
// Fixed wire bytes avoid a symmetric mistake in the test peer helpers.
fn bytes(hex: &str) -> Vec<u8> {
hex.as_bytes()
.as_chunks::<2>()
.0
.iter()
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect()
}
let init: [u8; 64] = bytes(concat!(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223",
"2425262728292a2b2c2d2e2f303132333435363738043de6c25808afa3"
))
.try_into()
.unwrap();
let parsed = parse_client_init(&init, &[42; 16]).unwrap();
assert_eq!(parsed.dc, 4);
assert!(parsed.media);
let mut relay = test_relay_peer(&parsed.relay_init);
let (mut upstream, mut downstream) = parsed.crypto.split();
let mut request = bytes("31fc48bfc21f9210a57fd63ac344ae50a3a23620");
for chunk in request.chunks_mut(3) {
upstream.apply(chunk);
relay.decrypt(chunk);
}
assert_eq!(request, bytes("10000000112233445566778899aabbccddeeff00"));
let mut reply = bytes("04000000ecfeffff");
relay.encrypt(&mut reply);
for chunk in reply.chunks_mut(1) {
downstream.apply(chunk);
}
assert_eq!(reply, bytes("d5a69e839ec08ebf"));
}
#[test]
fn parses_persisted_secret() {
assert_eq!(
@@ -698,4 +744,70 @@ mod tests {
);
assert_eq!(parse_secret_hex("not-a-secret"), None);
}
#[test]
fn secret_migration_keeps_legacy_links_and_prefers_existing_destination() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let destination = root.join("secret");
std::fs::write(&legacy, secret_hex(&[17; 16])).unwrap();
let first = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(first.value, [17; 16]);
assert!(first.is_persistent());
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[17; 16])
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&destination)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
std::fs::write(&legacy, secret_hex(&[23; 16])).unwrap();
let second = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(
second.value, [17; 16],
"an existing destination wins on restart"
);
std::fs::remove_file(destination).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
#[test]
fn failed_migration_reports_error_but_does_not_rotate_the_legacy_secret() {
let root = std::env::temp_dir().join(format!(
"tglock-migration-fail-{}",
secret_hex(&generate_secret())
));
std::fs::create_dir(&root).unwrap();
let legacy = root.join("legacy");
let blocker = root.join("blocker");
std::fs::write(&legacy, secret_hex(&[29; 16])).unwrap();
std::fs::write(&blocker, "not a directory").unwrap();
let destination = blocker.join("secret");
for _ in 0..2 {
let stored = load_or_create_secret_at_with_migration(&destination, Some(&legacy));
assert_eq!(stored.value, [29; 16]);
assert!(stored.write_error.is_some());
}
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
secret_hex(&[29; 16])
);
std::fs::remove_file(blocker).unwrap();
std::fs::remove_file(legacy).unwrap();
std::fs::remove_dir(root).unwrap();
}
}
+454 -19
View File
@@ -1,5 +1,5 @@
use crate::config::ListenConfig;
use std::collections::{HashSet, VecDeque};
use std::collections::VecDeque;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
@@ -71,15 +71,17 @@ pub struct Stats {
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
}
/// Однократные сообщения о том, что происходит с подключениями.
/// Сообщения о подключениях со склейкой недавних повторов.
///
/// Однократные намеренно: отклонённый адрес повторяется десятки раз в минуту,
/// Склейка намеренна: отклонённый адрес повторяется десятки раз в минуту,
/// и без склейки журнал превратился бы в одну строку, повторённую сто раз.
/// Число повторов при этом не теряется — оно в счётчике `blocked`.
#[derive(Default)]
struct Events {
pending: VecDeque<String>,
seen: HashSet<String>,
// FIFO of recent distinct messages, independent from the consumer queue.
// Once full, evict the oldest entry rather than silencing all future logs.
seen: VecDeque<String>,
}
/// Сколько разных событий помним, чтобы буфер не рос без границы.
@@ -132,13 +134,20 @@ impl Stats {
})
}
/// Запомнить событие, если такого ещё не было.
/// Запомнить событие, если его нет среди последних EVENT_LIMIT разных сообщений.
pub fn note(&self, message: impl Into<String>) {
let message = message.into();
let mut events = self.events.lock().unwrap();
if events.seen.len() >= EVENT_LIMIT || !events.seen.insert(message.clone()) {
if events.seen.contains(&message) {
return;
}
if events.seen.len() == EVENT_LIMIT {
events.seen.pop_front();
}
events.seen.push_back(message.clone());
if events.pending.len() == EVENT_LIMIT {
events.pending.pop_front();
}
events.pending.push_back(message);
}
@@ -683,13 +692,9 @@ fn dc_from_init(init: &[u8; 64]) -> Option<(u16, bool)> {
// -- WebSocket tunnel -------------------------------------------------------
/// Keeps `Stats::ws` equal to the number of *established* tunnels.
///
/// Counting attempts instead would let the interface announce «Telegram на
/// связи» while the WebSocket handshake is still failing over between routes,
/// which takes seconds per route. Reporting a working tunnel that does not
/// exist yet is the whole reason users saw «прокси подключён, а Telegram не
/// работает».
/// Counts opened upstream transports, not pending connection attempts.
/// WebSocket routes have completed their upgrade; native CDN routes have an
/// open TCP socket. Neither alone proves a successful Telegram protocol reply.
struct EstablishedTunnel<'a>(&'a Stats);
impl<'a> EstablishedTunnel<'a> {
@@ -715,7 +720,7 @@ async fn ws_tunnel(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use futures_util::{SinkExt, StreamExt};
let (ws, connected) = match stats.transport.connect(dc, media).await {
let (connection, connected) = match stats.transport.connect(dc, media).await {
Ok(connected) => connected,
Err(error) => {
// Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше
@@ -729,6 +734,13 @@ async fn ws_tunnel(
let _tunnel = EstablishedTunnel::new(stats);
stats.note_tunnel(dc, connected.route.kind.ui_code());
let ws = match connection {
crate::transport::TelegramConnection::WebSocket(ws) => *ws,
crate::transport::TelegramConnection::Tcp(remote) => {
return cdn_tcp_tunnel(tcp, remote, init, crypto, stats, &connected, dc).await;
}
};
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let (mut ws_w, mut ws_r) = ws.split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
@@ -738,7 +750,8 @@ async fn ws_tunnel(
// Send buffered init as first frame
ws_w.send(tungstenite::Message::Binary(init.to_vec()))
.await?;
.await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
// Ping приходит в половину, которая читает, а отвечать на него должна та,
// которая пишет: владелец у отправляющей половины строго один.
@@ -752,9 +765,11 @@ async fn ws_tunnel(
// сессия встаёт при живом туннеле (by-sonic/tglock#42, #32).
let downstream = async {
let mut crypto = downstream_crypto;
let mut received_payload = false;
while let Some(message) = ws_r.next().await {
match message {
Ok(tungstenite::Message::Binary(mut data)) => {
received_payload |= !data.is_empty();
if let Some(crypto) = &mut crypto {
crypto.apply(data.as_mut());
}
@@ -766,10 +781,48 @@ async fn ws_tunnel(
break;
}
}
Ok(tungstenite::Message::Close(_)) | Err(_) => break,
Ok(tungstenite::Message::Close(frame)) => {
// Telegram may send code 1000 even for transport errors;
// the actual error is a signed decimal in the reason.
// https://core.telegram.org/mtproto/transports#websocket
let transport_error = frame.as_ref().is_some_and(|frame| {
frame
.reason
.trim()
.parse::<i32>()
.is_ok_and(|code| code != 0)
|| frame.code != tungstenite::protocol::frame::coding::CloseCode::Normal
});
if !received_payload || transport_error {
let reason = match frame {
Some(frame) => {
format!("WebSocket закрыт: {} {}", frame.code, frame.reason)
}
None => "WebSocket закрыт без ответа Telegram".to_owned(),
};
return Err(tunnel_route_error(stats, &connected, dc, &reason));
}
return Ok(());
}
Err(error) => {
return Err(tunnel_route_error(
stats,
&connected,
dc,
&error.to_string(),
));
}
Ok(_) => {}
}
}
if !received_payload {
return Err(tunnel_route_error(
stats,
&connected,
dc,
"WebSocket завершён без ответа Telegram",
));
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
};
@@ -779,19 +832,22 @@ async fn ws_tunnel(
loop {
tokio::select! {
read = tcp_r.read(&mut buf) => match read {
Ok(0) | Err(_) => break,
Ok(0) => break,
Err(error) => return Err(error.into()),
Ok(read) => {
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buf[..read]);
}
ws_w
.send(tungstenite::Message::Binary(buf[..read].to_vec()))
.await?;
.await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
}
},
payload = pong_rx.recv() => match payload {
Some(payload) => {
ws_w.send(tungstenite::Message::Pong(payload)).await?;
ws_w.send(tungstenite::Message::Pong(payload)).await
.map_err(|error| tunnel_route_error(stats, &connected, dc, &error.to_string()))?;
}
None => break,
},
@@ -809,6 +865,94 @@ async fn ws_tunnel(
Ok(())
}
/// DC203's pinned CDN endpoint speaks obfuscated MTProto over TCP. Preserve the
/// same generated init and independent CTR streams used for a WebSocket route.
/// The transport engine restricts this connection to the exact CDN destination.
async fn cdn_tcp_tunnel(
client: TcpStream,
mut remote: TcpStream,
init: &[u8; 64],
crypto: Option<crate::mtproto::CryptoContext>,
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
remote
.write_all(init)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
let (mut client_r, mut client_w) = client.into_split();
let (mut remote_r, mut remote_w) = remote.into_split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
None => (None, None),
};
let upstream = async {
let mut crypto = upstream_crypto;
let mut buffer = vec![0; 65536];
loop {
let read = client_r.read(&mut buffer).await?;
if read == 0 {
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
}
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
remote_w
.write_all(&buffer[..read])
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
}
};
let downstream = async {
let mut crypto = downstream_crypto;
let mut buffer = vec![0; 65536];
let mut received_payload = false;
loop {
let read = remote_r
.read(&mut buffer)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
if read == 0 {
return if received_payload {
Ok(())
} else {
Err(tunnel_route_error(
stats,
connected,
dc,
"CDN TCP закрыт без ответа Telegram",
))
};
}
received_payload = true;
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
client_w.write_all(&buffer[..read]).await?;
}
};
tokio::select! {
result = upstream => result,
result = downstream => result,
}
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
reason: &str,
) -> Box<dyn std::error::Error + Send + Sync> {
stats.transport.report_route_failure(connected);
let message = format!(
"Сбой туннеля DC{dc} через {}: {reason}",
connected.route.connect_host
);
stats.note(message.clone());
message.into()
}
async fn tcp_relay(a: TcpStream, b: TcpStream) {
let (mut ar, mut aw) = tokio::io::split(a);
let (mut br, mut bw) = tokio::io::split(b);
@@ -953,6 +1097,7 @@ mod tests {
.send(Message::Binary(wire))
.await
.map_err(|e| e.to_string())?;
websocket.close(None).await.map_err(|e| e.to_string())?;
Ok((requested, init, request))
}
@@ -1040,6 +1185,52 @@ mod tests {
);
}
#[test]
fn diagnostics_keep_new_events_after_the_first_sixty_four() {
let stats = Stats::with_secret([0; 16]);
for index in 0..=EVENT_LIMIT {
stats.note(format!("route failure {index}"));
}
{
let events = stats.events.lock().unwrap();
assert_eq!(events.seen.len(), EVENT_LIMIT);
assert_eq!(events.pending.len(), EVENT_LIMIT);
}
let events = stats.drain_events();
assert_eq!(events.first().unwrap(), "route failure 1");
assert_eq!(
events.last().unwrap(),
&format!("route failure {EVENT_LIMIT}")
);
// Draining must not discard deduplication history, but must not stop
// the next new error from reaching the operator either.
stats.note(format!("route failure {EVENT_LIMIT}"));
stats.note("a new upstream error");
assert_eq!(stats.drain_events(), vec!["a new upstream error"]);
}
#[test]
fn an_evicted_event_can_be_reported_again_without_repeating_recent_events() {
let stats = Stats::with_secret([0; 16]);
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
stats.note("original error");
assert!(stats.drain_events().is_empty());
// More distinct events than the retained history used to silence
// diagnostics permanently, even when the frontend drained every one.
for index in 0..EVENT_LIMIT * 2 {
let message = format!("error {index}");
stats.note(message.clone());
assert_eq!(stats.drain_events(), vec![message]);
}
stats.note("original error");
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
assert_eq!(stats.events.lock().unwrap().seen.len(), EVENT_LIMIT);
}
/// Диагностика обязана показывать пару из одного соединения.
///
/// Пока это были два независимых поля, при десятках одновременных
@@ -1411,6 +1602,250 @@ mod tests {
let _ = server.await.unwrap();
}
#[allow(clippy::result_large_err)]
#[tokio::test]
async fn post_handshake_failures_are_reported_and_penalize_the_route() {
use futures_util::{SinkExt, StreamExt};
use tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
// No reply, abrupt socket loss, and a Telegram transport error carried
// in a nominally normal WebSocket close all used to disappear silently.
for mode in 0..4 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(
tcp,
|_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
},
)
.await
.unwrap();
assert!(matches!(ws.next().await, Some(Ok(Message::Binary(_)))));
if mode >= 2 {
ws.send(Message::Binary(vec![1, 2, 3, 4])).await.unwrap();
}
if mode != 1 {
ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: if mode == 2 { " -404 " } else { "" }.into(),
}))
.await
.unwrap();
}
});
let stats = Stats::new();
stats.transport.force_local_route(relay_port);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 2))
.await
.unwrap();
let mut received = Vec::new();
tokio::time::timeout(Duration::from_secs(5), client.read_to_end(&mut received))
.await
.unwrap()
.unwrap();
relay.await.unwrap();
wait_until("connection task finished", || {
stats.active.load(Ordering::Relaxed) == 0
})
.await;
let failed = u32::from(mode != 3);
assert_eq!(
stats.ws_failures.load(Ordering::Relaxed),
failed,
"mode {mode}"
);
assert_eq!(stats.transport.route_failures(), failed, "mode {mode}");
let events = stats.drain_events();
assert_eq!(
events
.iter()
.any(|event| event.contains("Сбой туннеля DC2 через 127.0.0.1")),
mode != 3
);
if mode == 2 {
assert!(events.iter().any(|event| event.contains("-404")));
}
stats.stop();
server.await.unwrap().unwrap();
}
}
#[tokio::test]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
check_fragmented_encrypted_duplex(false).await;
}
#[tokio::test]
async fn cdn_tcp_preserves_fragmented_encrypted_duplex_and_clean_close() {
check_fragmented_encrypted_duplex(true).await;
}
#[tokio::test]
async fn cdn_tcp_closing_before_a_reply_is_reported_as_a_route_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut init = [0; INIT_LEN];
stream.read_exact(&mut init).await.unwrap();
});
let stats = Stats::new();
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 203))
.await
.unwrap();
wait_until("CDN failure is counted", || {
stats.ws_failures.load(Ordering::Relaxed) == 1
})
.await;
assert_eq!(stats.transport.route_failures(), 1);
assert!(stats
.drain_events()
.iter()
.any(|event| event.contains("CDN TCP закрыт без ответа")));
relay.await.unwrap();
stats.stop();
server.await.unwrap().unwrap();
}
#[allow(clippy::result_large_err)]
async fn check_fragmented_encrypted_duplex(raw_tcp: bool) {
use futures_util::{SinkExt, StreamExt};
let request: Vec<u8> = (0..131_072).map(|i| (i % 251) as u8).collect();
let response: Vec<u8> = (0..262_144).map(|i| (i % 239) as u8).collect();
let expected_request = request.clone();
let relay_response = response.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut tcp, _) = listener.accept().await.unwrap();
if raw_tcp {
let mut init = [0; INIT_LEN];
tcp.read_exact(&mut init).await.unwrap();
let mut peer = crate::mtproto::test_relay_peer(&init);
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut reader, mut writer) = tcp.into_split();
let send = async {
for chunk in download.chunks(8191) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; expected_request.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
writer.shutdown().await.unwrap();
return;
}
let mut ws =
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
response
.headers_mut()
.insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
})
.await
.unwrap();
let init = match ws.next().await.unwrap().unwrap() {
Message::Binary(init) => init,
other => panic!("expected init: {other:?}"),
};
let mut peer = crate::mtproto::test_relay_peer(&init.try_into().unwrap());
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut sink, mut stream) = ws.split();
let send = async {
for chunk in download.chunks(8191) {
sink.send(Message::Binary(chunk.to_vec())).await.unwrap();
}
};
let receive = async {
let mut received = Vec::new();
while received.len() < expected_request.len() {
if let Message::Binary(mut chunk) = stream.next().await.unwrap().unwrap() {
peer.decrypt(&mut chunk);
received.extend_from_slice(&chunk);
}
}
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
sink.close().await.unwrap();
});
let stats = Stats::new();
if raw_tcp {
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
} else {
stats.transport.force_local_route(relay_port);
}
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, if raw_tcp { 203 } else { -4 });
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
// A mobile TCP stream can split the 64-byte init and subsequent data
// at arbitrary boundaries. WebSocket boundaries need not match these.
for chunk in init.chunks(7) {
client.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
let mut upload = request;
peer.encrypt(&mut upload);
let (mut reader, mut writer) = client.into_split();
let send = async {
for chunk in upload.chunks(137) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; response.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, response);
};
tokio::time::timeout(Duration::from_secs(10), async {
tokio::join!(send, receive);
relay.await.unwrap();
})
.await
.expect("both encrypted directions must make progress");
assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 0);
if raw_tcp {
assert_eq!(
stats.last_route(),
crate::transport::RouteKind::TelegramTcp.ui_code()
);
assert_eq!(stats.last_dc(), 203);
}
stats.stop();
server.await.unwrap().unwrap();
}
#[tokio::test]
async fn connects_through_the_documented_worker_contract() {
// Locks the contract in docs/CLOUDFLARE_WORKER.md: a server that
+558 -73
View File
@@ -1,25 +1,38 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use futures_util::stream::{FuturesUnordered, StreamExt};
use tokio::net::TcpStream;
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
const FALLBACK_DELAY: Duration = Duration::from_millis(250);
const MAX_CONNECTING: usize = 3;
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
const HTTPS_PORT: u16 = 443;
const CDN203_IP: &str = "91.105.192.100";
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug)]
pub enum TelegramConnection {
WebSocket(Box<TelegramWebSocket>),
/// Native obfuscated2 transport, currently restricted to the CDN203 IP.
Tcp(TcpStream),
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RouteKind {
TelegramIp,
AlternateTelegramIp,
SystemDns,
CloudflareWorker,
TelegramTcp,
}
impl RouteKind {
@@ -29,6 +42,7 @@ impl RouteKind {
Self::AlternateTelegramIp => 2,
Self::SystemDns => 3,
Self::CloudflareWorker => 4,
Self::TelegramTcp => 5,
}
}
@@ -38,6 +52,7 @@ impl RouteKind {
2 => Some(Self::AlternateTelegramIp),
3 => Some(Self::SystemDns),
4 => Some(Self::CloudflareWorker),
5 => Some(Self::TelegramTcp),
_ => None,
}
}
@@ -49,6 +64,7 @@ impl RouteKind {
Self::AlternateTelegramIp => "Запасной Telegram IP",
Self::SystemDns => "Системный DNS",
Self::CloudflareWorker => "Cloudflare Worker",
Self::TelegramTcp => "Telegram CDN TCP",
}
}
}
@@ -69,11 +85,22 @@ pub struct Route {
pub kind: RouteKind,
/// TCP port to dial. Always 443 for Telegram and for Cloudflare Workers.
pub port: u16,
/// Wrap the connection in TLS. Always true outside tests.
/// TLS for WebSocket routes; false for native obfuscated2 CDN TCP.
pub secure: bool,
}
impl Route {
fn cdn_tcp() -> Self {
Self {
connect_host: CDN203_IP.to_owned(),
websocket_host: String::new(),
path: String::new(),
kind: RouteKind::TelegramTcp,
port: HTTPS_PORT,
secure: false,
}
}
/// A production route: TLS on 443.
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
Self {
@@ -189,39 +216,104 @@ impl TransportEngine {
&self,
dc: u16,
media: bool,
) -> Result<(TelegramWebSocket, ConnectedRoute), String> {
) -> Result<(TelegramConnection, ConnectedRoute), String> {
let key = DcKey { dc, media };
let candidates = self.ordered_candidates(key);
self.race_connections(key, |route| async move { connect_route(&route).await })
.await
}
// A slow first IP must not hold every other route hostage. At most three
// handshakes run per client, staggered so a healthy preferred route wins
// without opening needless fallback sockets. Dropping this future or
// returning a winner cancels every losing connection attempt.
async fn race_connections<T, F, Fut>(
&self,
key: DcKey,
mut connect: F,
) -> Result<(T, ConnectedRoute), String>
where
F: FnMut(Route) -> Fut,
Fut: std::future::Future<Output = Result<T, String>>,
{
let mut candidates = self.ordered_candidates(key).into_iter().peekable();
let mut pending = FuturesUnordered::new();
let mut next_start = Instant::now();
let mut errors = Vec::new();
for route in candidates {
match connect_route(&route).await {
Ok(websocket) => {
self.record_success(key, &route);
return Ok((websocket, ConnectedRoute { route }));
while candidates.peek().is_some() || !pending.is_empty() {
if candidates.peek().is_some()
&& pending.len() < MAX_CONNECTING
&& (pending.is_empty() || Instant::now() >= next_start)
{
let route = candidates.next().unwrap();
// Another client may have failed this route since we took our
// snapshot. Never knowingly bypass its current cooldown.
if !self.route_available(&route) {
continue;
}
Err(error) => {
self.record_failure(&route);
let attempt = format!("{} — {}", route.connect_host, error);
if !errors.contains(&attempt) {
errors.push(attempt);
let connection = connect(route.clone());
pending.push(async move { (route, connection.await) });
next_start = Instant::now() + FALLBACK_DELAY;
continue;
}
tokio::select! {
Some((route, result)) = pending.next(), if !pending.is_empty() => {
match result {
Ok(connection) => {
self.record_success(key, &route);
return Ok((connection, ConnectedRoute { route }));
}
Err(error) => {
self.record_failure(&route);
let endpoint = if route.kind == RouteKind::TelegramTcp {
format!("{}:{} (MTProto TCP)", route.connect_host, route.port)
} else {
format!("{} (TLS {})", route.connect_host, route.websocket_host)
};
errors.push(format!("{endpoint} — {error}"));
}
}
}
_ = tokio::time::sleep_until(next_start),
if candidates.peek().is_some() && pending.len() < MAX_CONNECTING => {}
}
}
// Текст читает человек: он попадает в журнал событий, и по нему
// отличают «провайдер режет закреплённые адреса» от «воркер отвечает
// отказом». Раньше причина отказа не доходила никуда, и при
// `туннелей 0` узнать, почему их ноль, было нечем (by-sonic/tglock#50).
if errors.is_empty() {
let routes = self.routes_for_key(key);
let health = self.health.lock().unwrap();
let retry = routes
.iter()
.filter_map(|route| health.routes.get(route))
.map(|entry| entry.retry_at.saturating_duration_since(Instant::now()))
.min();
errors.push(match retry {
Some(delay) => format!(
"маршруты на паузе после ошибок; повтор через {} с",
delay
.as_secs()
.saturating_add(u64::from(delay.subsec_nanos() != 0))
),
None => "для этого дата-центра нет маршрутов".to_owned(),
});
}
Err(format!(
"Не поднялся туннель до DC{}{}: {}",
dc,
if media { " (медиа)" } else { "" },
key.dc,
if key.media { " (медиа)" } else { "" },
errors.join("; ")
))
}
fn route_available(&self, route: &Route) -> bool {
self.health
.lock()
.unwrap()
.routes
.get(route)
.is_none_or(|health| health.retry_at <= Instant::now())
}
fn ordered_candidates(&self, key: DcKey) -> Vec<Route> {
let now = Instant::now();
let all_routes = self.routes_for_key(key);
@@ -241,25 +333,18 @@ impl TransportEngine {
candidates.sort_by_key(|route| {
let preferred_rank = u8::from(preferred != Some(route));
let kind_rank = match route.kind {
RouteKind::TelegramIp => 0,
RouteKind::AlternateTelegramIp => 1,
RouteKind::SystemDns => 2,
RouteKind::CloudflareWorker => 3,
RouteKind::TelegramTcp => 0,
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
// The operator explicitly configured this independent path.
// Start it after the primary IP, before more potentially
// blocked Telegram addresses consume the concurrency budget.
RouteKind::CloudflareWorker => 1,
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
RouteKind::SystemDns => 3,
};
(preferred_rank, kind_rank)
});
// If every route is cooling down, retry the one that becomes available first.
if candidates.is_empty() {
if let Some((route, _)) = health
.routes
.iter()
.filter(|(route, _)| all_routes.contains(route))
.min_by_key(|(_, route_health)| route_health.retry_at)
{
candidates.push(route.clone());
}
}
candidates
}
@@ -299,9 +384,17 @@ impl TransportEngine {
self.route_failures.load(Ordering::Relaxed)
}
/// A successful WebSocket upgrade is not proof that its upstream works.
/// Call this for upstream failures after the handshake, never merely for
/// a client disconnect or a canceled losing connection attempt.
pub fn report_route_failure(&self, connected: &ConnectedRoute) {
self.record_failure(&connected.route);
}
fn record_failure(&self, route: &Route) {
self.route_failures.fetch_add(1, Ordering::Relaxed);
let mut health = self.health.lock().unwrap();
health.preferred.retain(|_, preferred| preferred != route);
let failures = health
.routes
.get(route)
@@ -357,12 +450,15 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
3 => &["149.154.175.100"],
4 => &["149.154.167.91", "149.154.167.220"],
5 => &["149.154.171.5"],
203 => &["91.105.192.100"],
203 => &[CDN203_IP],
_ => &[],
}
}
pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
if telegram_ips(dc).is_empty() {
return Vec::new();
}
let websocket_dc = canonical_dc(dc);
let primary = format!("kws{}.web.telegram.org", websocket_dc);
let secondary = format!("kws{}-1.web.telegram.org", websocket_dc);
@@ -373,6 +469,13 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
};
let ips = telegram_ips(dc);
let mut routes = Vec::new();
if dc == 203 {
// CDN203 speaks native obfuscated2 on this exact IP:443. A WebSocket
// handshake on the same address can time out even while MTProto is
// healthy. Keep the destination unchanged and use its native wire
// transport; never redirect CDN authorization to ordinary DC2.
routes.push(Route::cdn_tcp());
}
for websocket_host in &websocket_hosts {
for (index, ip) in ips.iter().enumerate() {
@@ -387,17 +490,57 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
},
));
}
routes.push(Route::https(
websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
// DC203 uses the DC2 hostname only for TLS/HTTP virtual hosting.
// Resolving it would connect to DC2 itself and send CDN sessions to
// the wrong data centre. Its fallback must retain the CDN address.
if dc != 203 {
routes.push(Route::https(
websocket_host.clone(),
websocket_host.clone(),
"/apiws".to_owned(),
RouteKind::SystemDns,
));
}
}
routes
}
async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
async fn connect_route(route: &Route) -> Result<TelegramConnection, String> {
connect_route_with_config(route, tls_config()).await
}
fn client_config(roots: rustls::RootCertStore) -> Arc<rustls::ClientConfig> {
Arc::new(
rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("ring supports the default TLS protocol versions")
.with_root_certificates(roots)
.with_no_client_auth(),
)
}
fn tls_config() -> Arc<rustls::ClientConfig> {
static CONFIG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
CONFIG
.get_or_init(|| {
client_config(rustls::RootCertStore::from_iter(
webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
))
})
.clone()
}
async fn connect_route_with_config(
route: &Route,
config: Arc<rustls::ClientConfig>,
) -> Result<TelegramConnection, String> {
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
// hatch. Reject malformed raw routes before dialing anything.
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
return Err("native MTProto TCP разрешён только для закреплённого CDN203".to_owned());
}
let tcp = tokio::time::timeout(
CONNECT_TIMEOUT,
TcpStream::connect((route.connect_host.as_str(), route.port)),
@@ -408,6 +551,10 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
tcp.set_nodelay(true)
.map_err(|error| format!("TCP_NODELAY: {}", error))?;
if route.kind == RouteKind::TelegramTcp {
return Ok(TelegramConnection::Tcp(tcp));
}
let scheme = if route.secure { "wss" } else { "ws" };
let url = format!("{}://{}{}", scheme, route.websocket_host, route.path);
let mut request = url
@@ -422,33 +569,45 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
);
if !route.secure {
// Only reachable from tests, which run a local WebSocket server without
// a certificate. Production routes are always built by `Route::https`.
// Raw TCP has already returned above. Plain WebSocket is only used by
// local fixtures; production WebSocket routes are always HTTPS.
return tokio::time::timeout(
CONNECT_TIMEOUT,
tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)),
)
.await
.map_err(|_| "таймаут WebSocket".to_owned())?
.map(|(websocket, _)| websocket)
.map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("рукопожатие WebSocket: {}", error));
}
// The URI host remains the real Telegram hostname even when the TCP socket
// is opened to a pinned IP. Native TLS therefore validates Telegram's
// certificate and sends the correct SNI.
let tls = native_tls::TlsConnector::new().map_err(|error| format!("TLS setup: {}", error))?;
let connector = tokio_tungstenite::Connector::NativeTls(tls);
// is opened to a pinned IP. rustls validates that hostname and certificate
// against the bundled roots, and sends that hostname in SNI. The explicit
// ring provider avoids process-global provider selection and its panics.
let connector = tokio_tungstenite::Connector::Rustls(config);
tokio::time::timeout(
CONNECT_TIMEOUT,
tokio_tungstenite::client_async_tls_with_config(request, tcp, None, Some(connector)),
)
.await
.map_err(|_| "таймаут TLS/WebSocket".to_owned())?
.map(|(websocket, _)| websocket)
.map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("TLS/WebSocket handshake: {}", error))
}
fn allowed_tcp_route(route: &Route) -> bool {
#[cfg(test)]
if route.connect_host == "127.0.0.1" && !route.secure {
return true;
}
route.connect_host == CDN203_IP
&& route.port == HTTPS_PORT
&& route.websocket_host.is_empty()
&& route.path.is_empty()
&& !route.secure
}
fn valid_domain(domain: &str) -> bool {
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
return false;
@@ -476,10 +635,35 @@ mod tests {
}
#[test]
fn dc203_uses_dc2_websocket_and_its_own_ip() {
fn dc203_prefers_native_tcp_and_never_changes_the_cdn_destination() {
let routes = routes_for_dc(203, false);
assert_eq!(routes[0].websocket_host, "kws2.web.telegram.org");
assert_eq!(routes[0].kind, RouteKind::TelegramTcp);
assert!(routes[0].websocket_host.is_empty());
assert!(!routes[0].secure);
assert_eq!(routes[0].connect_host, "91.105.192.100");
assert_eq!(routes[1].websocket_host, "kws2.web.telegram.org");
assert!(routes
.iter()
.all(|route| route.connect_host == "91.105.192.100"));
assert!(!routes
.iter()
.any(|route| route.kind == RouteKind::SystemDns));
let engine = TransportEngine::new();
engine.set_worker_domains(&["cdn.workers.dev".to_owned()]);
let routes = engine.routes_for_key(DcKey {
dc: 203,
media: true,
});
let ordered = engine.ordered_candidates(DcKey {
dc: 203,
media: true,
});
assert_eq!(ordered[0].kind, RouteKind::TelegramTcp);
assert_eq!(ordered[1].kind, RouteKind::CloudflareWorker);
assert!(routes
.iter()
.filter(|route| route.kind == RouteKind::CloudflareWorker)
.all(|route| route.path == "/apiws?dst=91.105.192.100&dc=203"));
}
#[test]
@@ -530,7 +714,7 @@ mod tests {
}
#[test]
fn every_production_route_is_tls_on_443() {
fn production_websockets_use_tls_and_native_tcp_is_restricted_to_cdn203() {
let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
for dc in [1, 2, 3, 4, 5, 203] {
@@ -539,7 +723,13 @@ mod tests {
assert!(!routes.is_empty(), "DC{dc} must have at least one route");
for route in routes {
assert_eq!(route.port, 443, "{route:?}");
assert!(route.secure, "{route:?}");
if route.kind == RouteKind::TelegramTcp {
assert_eq!(dc, 203);
assert_eq!(route.connect_host, CDN203_IP);
assert!(allowed_tcp_route(&route));
} else {
assert!(route.secure, "{route:?}");
}
}
}
}
@@ -547,7 +737,7 @@ mod tests {
#[test]
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
for dc in [1, 2, 3, 4, 5, 203] {
for dc in [1, 2, 3, 4, 5] {
let routes = routes_for_dc(dc, false);
assert!(
routes
@@ -611,7 +801,7 @@ mod tests {
}
#[test]
fn all_routes_cooling_down_still_yields_the_soonest_retry() {
fn all_routes_cooling_down_do_not_bypass_the_backoff() {
let engine = TransportEngine::new();
let key = DcKey {
dc: 5,
@@ -627,13 +817,7 @@ mod tests {
engine.record_failure(route);
}
let candidates = engine.ordered_candidates(key);
assert_eq!(
candidates.len(),
1,
"a fully cooling table must offer exactly one retry, not give up"
);
assert_eq!(candidates[0], routes[0]);
assert!(engine.ordered_candidates(key).is_empty());
}
#[test]
@@ -694,7 +878,7 @@ mod tests {
}
#[test]
fn worker_is_the_last_resort() {
fn explicit_worker_is_tried_before_redundant_telegram_fallbacks() {
let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
let key = DcKey {
@@ -702,10 +886,19 @@ mod tests {
media: false,
};
let candidates = engine.ordered_candidates(key);
assert_eq!(
candidates.last().unwrap().kind,
RouteKind::CloudflareWorker,
"third-party infrastructure must never be tried before Telegram itself"
assert_eq!(candidates[0].kind, RouteKind::TelegramIp);
assert_eq!(candidates[1].kind, RouteKind::CloudflareWorker);
let worker = candidates
.iter()
.position(|route| route.kind == RouteKind::CloudflareWorker)
.unwrap();
let dns = candidates
.iter()
.position(|route| route.kind == RouteKind::SystemDns)
.unwrap();
assert!(
worker < dns,
"an explicitly configured independent path must not wait for every blocked IP"
);
}
@@ -781,6 +974,7 @@ mod tests {
RouteKind::AlternateTelegramIp,
RouteKind::SystemDns,
RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] {
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
assert_eq!(route_label(kind.ui_code()), kind.label());
@@ -796,19 +990,310 @@ mod tests {
RouteKind::AlternateTelegramIp,
RouteKind::SystemDns,
RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] {
assert_ne!(route_label(0), kind.label());
}
}
fn local_routes(count: usize) -> Vec<Route> {
(0..count)
.map(|index| Route {
connect_host: "127.0.0.1".to_owned(),
websocket_host: "localhost".to_owned(),
path: format!("/{index}"),
kind: RouteKind::TelegramIp,
port: 443,
secure: false,
})
.collect()
}
struct ActiveAttempt(Arc<std::sync::atomic::AtomicUsize>);
impl Drop for ActiveAttempt {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[tokio::test(start_paused = true)]
async fn a_hanging_first_route_does_not_delay_a_working_fallback() {
let engine = TransportEngine::new();
*engine.forced_routes.lock().unwrap() = local_routes(3);
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let started = Instant::now();
let (_, winner) = engine
.race_connections(
DcKey {
dc: 2,
media: false,
},
|route| {
let active = active.clone();
async move {
active.fetch_add(1, Ordering::SeqCst);
let _active = ActiveAttempt(active);
if route.path == "/1" {
tokio::time::sleep(Duration::from_millis(1)).await;
Ok(())
} else {
std::future::pending::<Result<(), String>>().await
}
}
},
)
.await
.unwrap();
assert_eq!(winner.route.path, "/1");
assert_eq!(started.elapsed(), FALLBACK_DELAY + Duration::from_millis(1));
assert_eq!(
active.load(Ordering::SeqCst),
0,
"losing attempts must be canceled"
);
assert_eq!(
engine.route_failures(),
0,
"cancellation is not a route failure"
);
}
#[tokio::test(start_paused = true)]
async fn connection_races_are_bounded_and_cooldown_prevents_redialing() {
let engine = TransportEngine::new();
*engine.forced_routes.lock().unwrap() = local_routes(8);
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let peak = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let key = DcKey {
dc: 2,
media: false,
};
let error = engine
.race_connections(key, |_| {
let active = active.clone();
let peak = peak.clone();
async move {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(current, Ordering::SeqCst);
let _active = ActiveAttempt(active);
tokio::time::sleep(Duration::from_secs(1)).await;
Err::<(), _>("test handshake failure".to_owned())
}
})
.await
.unwrap_err();
assert!(error.contains("test handshake failure"));
assert_eq!(peak.load(Ordering::SeqCst), MAX_CONNECTING);
assert_eq!(engine.route_failures(), 8);
let error = engine
.race_connections(key, |_| {
panic!("a route in cooldown must never be dialed");
#[allow(unreachable_code)]
std::future::ready(Ok::<(), String>(()))
})
.await
.unwrap_err();
assert!(error.contains("повтор через"));
assert_eq!(engine.route_failures(), 8);
tokio::time::advance(FAILURE_BACKOFF_INITIAL).await;
assert_eq!(engine.ordered_candidates(key).len(), 8);
}
#[test]
fn an_upstream_failure_removes_preference_and_starts_cooldown() {
let engine = TransportEngine::new();
let key = DcKey {
dc: 2,
media: false,
};
let route = routes_for_dc(2, false)[0].clone();
engine.record_success(key, &route);
engine.report_route_failure(&ConnectedRoute {
route: route.clone(),
});
assert!(!engine.ordered_candidates(key).contains(&route));
assert!(!engine.health.lock().unwrap().preferred.contains_key(&key));
assert_eq!(engine.route_failures(), 1);
}
#[tokio::test]
async fn unsupported_dc_reports_missing_routes_without_dialing() {
let error = TransportEngine::new()
.connect(999, false)
.await
.unwrap_err();
assert!(error.contains("нет маршрутов"));
}
async fn local_tls_route(
hostname: &str,
) -> (
Route,
Arc<rustls::ClientConfig>,
tokio::task::JoinHandle<Option<String>>,
) {
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap();
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.key_pair.serialize_der());
let cert = certificate.cert.der().clone();
let mut roots = rustls::RootCertStore::empty();
roots.add(cert.clone()).unwrap();
let server = rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert], key.into())
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server));
let Ok(tls) = acceptor.accept(socket).await else {
return None;
};
let name = tls.get_ref().1.server_name().map(str::to_owned);
#[allow(clippy::result_large_err)]
let negotiate = |_: &tokio_tungstenite::tungstenite::handshake::server::Request,
mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
response.headers_mut().insert("Sec-WebSocket-Protocol", "binary".parse().unwrap());
Ok(response)
};
let _ = tokio_tungstenite::accept_hdr_async(tls, negotiate).await;
name
});
(
Route {
connect_host: "127.0.0.1".to_owned(),
websocket_host: hostname.to_owned(),
path: "/apiws".to_owned(),
kind: RouteKind::TelegramIp,
port,
secure: true,
},
client_config(roots),
task,
)
}
#[tokio::test]
async fn pinned_ip_tls_uses_uri_hostname_for_sni_and_certificate_validation() {
let (route, config, server) = local_tls_route("localhost").await;
connect_route_with_config(&route, config).await.unwrap();
assert_eq!(server.await.unwrap().as_deref(), Some("localhost"));
}
#[tokio::test]
async fn tls_rejects_a_trusted_certificate_for_a_different_hostname() {
let (route, config, server) = local_tls_route("wrong.example").await;
let error = connect_route_with_config(&route, config).await.unwrap_err();
assert!(error.contains("TLS/WebSocket handshake"), "{error}");
assert!(server.await.unwrap().is_none());
}
#[tokio::test]
async fn production_roots_reject_an_untrusted_certificate() {
let (route, _, server) = local_tls_route("localhost").await;
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("TLS/WebSocket handshake"), "{error}");
assert!(server.await.unwrap().is_none());
assert!(
Arc::ptr_eq(&tls_config(), &tls_config()),
"reuse the TLS configuration"
);
}
#[tokio::test]
async fn native_tcp_connects_without_sending_a_tls_or_websocket_handshake() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let engine = TransportEngine::new();
engine.force_local_route_with(
listener.local_addr().unwrap().port(),
RouteKind::TelegramTcp,
String::new(),
);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut bytes = [0; 4];
socket.read_exact(&mut bytes).await.unwrap();
assert_eq!(
&bytes, b"init",
"first bytes must be native transport bytes"
);
socket.write_all(b"pong").await.unwrap();
});
let (connection, connected) =
tokio::time::timeout(Duration::from_secs(1), engine.connect(203, true))
.await
.unwrap()
.unwrap();
assert_eq!(connected.route.kind, RouteKind::TelegramTcp);
let TelegramConnection::Tcp(mut socket) = connection else {
panic!("native CDN route must return a TCP stream");
};
assert!(socket.nodelay().unwrap());
socket.write_all(b"init").await.unwrap();
let mut reply = [0; 4];
tokio::time::timeout(Duration::from_secs(1), socket.read_exact(&mut reply))
.await
.unwrap()
.unwrap();
assert_eq!(&reply, b"pong");
server.await.unwrap();
}
#[tokio::test]
async fn raw_tcp_failure_is_named_and_enters_cooldown() {
let engine = TransportEngine::new();
// Port zero cannot accept a TCP connection; no external traffic.
engine.force_local_route_with(0, RouteKind::TelegramTcp, String::new());
let error = engine.connect(203, false).await.unwrap_err();
assert!(error.contains("MTProto TCP"), "{error}");
assert!(
!error.contains("TLS"),
"native transport must not be called TLS"
);
assert_eq!(engine.route_failures(), 1);
assert!(engine
.ordered_candidates(DcKey {
dc: 203,
media: false
})
.is_empty());
}
#[tokio::test]
async fn raw_tcp_guard_rejects_a_different_destination_before_dialing() {
let mut route = Route::cdn_tcp();
route.connect_host = "127.0.0.2".to_owned();
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("только для закреплённого CDN203"), "{error}");
for dc in [1, 2, 3, 4, 5, 999] {
assert!(routes_for_dc(dc, false)
.iter()
.all(|route| route.kind != RouteKind::TelegramTcp));
}
}
#[tokio::test]
#[ignore = "requires live Telegram network access"]
async fn connects_to_all_production_data_centers() {
let engine = TransportEngine::new();
for dc in [1, 2, 3, 4, 5, 203] {
let (mut websocket, connected) = engine.connect(dc, false).await.unwrap();
assert!(!connected.route.websocket_host.is_empty());
websocket.close(None).await.unwrap();
let (connection, connected) = engine.connect(dc, false).await.unwrap();
assert!(!connected.route.connect_host.is_empty());
match connection {
TelegramConnection::WebSocket(mut websocket) => {
websocket.close(None).await.unwrap()
}
TelegramConnection::Tcp(mut socket) => {
use tokio::io::AsyncWriteExt;
socket.shutdown().await.unwrap();
}
}
}
}
}