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, 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, logs: Vec, } struct AppState { stats: Arc, settings: Mutex, active_port: Mutex, /// Слушатель работающего прокси. Нужен, чтобы показать адрес для других /// устройств именно тот, на котором прокси реально поднят, а не тот, что /// сейчас выбран в настройках. active_listen: Mutex>, started_at: Mutex>, logs: Arc>>, settings_path: PathBuf, task: tokio::sync::Mutex>>, } impl AppState { fn new(settings_path: PathBuf, stats: Arc) -> 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, 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) -> Option { 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 { 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 { // 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 { 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 { 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 { 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); #[cfg(target_os = "android")] fn set_foreground_service(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> { app.state::() .0 .run_mobile_plugin::( "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>>, 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::::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" ] ); } } }