mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
fix: address routing, media, worker and platform issues
This commit is contained in:
+2
-2
@@ -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);
|
||||
|
||||
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
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».
|
||||
///
|
||||
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
|
||||
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
|
||||
/// (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,
|
||||
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>) -> StatusSnapshot {
|
||||
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);
|
||||
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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
+118
-8
@@ -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,48 @@ 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()
|
||||
.chunks_exact(2)
|
||||
.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 +742,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();
|
||||
}
|
||||
}
|
||||
|
||||
+226
-5
@@ -738,7 +738,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 +753,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 +769,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 +820,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 +853,21 @@ async fn ws_tunnel(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 +1012,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))
|
||||
}
|
||||
|
||||
@@ -1411,6 +1471,167 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
#[tokio::test]
|
||||
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
|
||||
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 (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();
|
||||
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();
|
||||
stats.transport.force_local_route(relay_port);
|
||||
let (port, server) = start_proxy(stats.clone(), false).await;
|
||||
let init = unambiguous_client_init(&stats.secret, -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");
|
||||
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
|
||||
|
||||
+388
-58
@@ -1,13 +1,17 @@
|
||||
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;
|
||||
@@ -191,37 +195,97 @@ impl TransportEngine {
|
||||
media: bool,
|
||||
) -> Result<(TelegramWebSocket, 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);
|
||||
errors.push(format!("{} (TLS {}) — {}", route.connect_host, route.websocket_host, 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 +305,17 @@ 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::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 +355,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)
|
||||
@@ -363,6 +427,9 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -387,17 +454,52 @@ 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> {
|
||||
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<TelegramWebSocket, String> {
|
||||
let tcp = tokio::time::timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
TcpStream::connect((route.connect_host.as_str(), route.port)),
|
||||
@@ -435,10 +537,10 @@ async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
|
||||
}
|
||||
|
||||
// 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)),
|
||||
@@ -480,6 +582,22 @@ mod tests {
|
||||
let routes = routes_for_dc(203, false);
|
||||
assert_eq!(routes[0].websocket_host, "kws2.web.telegram.org");
|
||||
assert_eq!(routes[0].connect_host, "91.105.192.100");
|
||||
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,
|
||||
});
|
||||
assert!(routes
|
||||
.iter()
|
||||
.filter(|route| route.kind == RouteKind::CloudflareWorker)
|
||||
.all(|route| route.path == "/apiws?dst=91.105.192.100&dc=203"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -547,7 +665,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 +729,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 +745,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 +806,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 +814,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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -801,6 +922,215 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
#[ignore = "requires live Telegram network access"]
|
||||
async fn connects_to_all_production_data_centers() {
|
||||
|
||||
Reference in New Issue
Block a user