diff --git a/Cargo.lock b/Cargo.lock index 632cd9a..49ee890 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3822,7 +3822,7 @@ dependencies = [ [[package]] name = "tglock" -version = "2.0.0-beta.16" +version = "2.0.0-beta.17" dependencies = [ "aes", "cipher", diff --git a/Cargo.toml b/Cargo.toml index 270b51c..714a195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tglock" -version = "2.0.0-beta.16" +version = "2.0.0-beta.17" edition = "2021" rust-version = "1.88" description = "Telegram unblock via local WebSocket tunnel" diff --git a/docs/CF_PROXY.md b/docs/CF_PROXY.md new file mode 100644 index 0000000..46ef2ac --- /dev/null +++ b/docs/CF_PROXY.md @@ -0,0 +1,49 @@ +# Свой домен на Cloudflare (CF-прокси) + +Ещё один резервный маршрут на случай, когда провайдер режет все обычные адреса +Telegram. В отличие от [Cloudflare Worker](CLOUDFLARE_WORKER.md) здесь нет +скрипта и лимитов Worker'ов: Cloudflare сам проксирует WebSocket до Telegram +по DNS-записям вашего домена. Нужен только домен (подойдёт любой, обычно это +150–300 ₽ в год). + +Схема та же, что у CF-прокси в [Flowseal/tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/CfProxy.md) +(идея — [Nekogram/WSProxy](https://github.com/Nekogram/WSProxy)): + +``` +Telegram → TGLock → wss://kws2.ваш-домен/apiws → Cloudflare → 149.154.167.51 (DC2) +``` + +## Настройка + +1. Добавьте домен в Cloudflare: купите его там или смените NS-серверы у + регистратора ([инструкция Cloudflare](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)). +2. **SSL/TLS → Overview** — режим **Flexible**. До Cloudflare соединение + остаётся HTTPS с проверкой сертификата, а от Cloudflare до Telegram идёт по + порту 80: у адресов Telegram нет сертификата на ваш домен. +3. **DNS → Records** — `A`-записи с включённым проксированием (оранжевое облако): + + | Name | IPv4 | + |---|---| + | `kws1` | `149.154.175.50` | + | `kws2` | `149.154.167.51` | + | `kws3` | `149.154.175.100` | + | `kws4` | `149.154.167.91` | + | `kws5` | `149.154.171.5` | + | `kws203` | `91.105.192.100` | + +4. Укажите в TGLock **только домен**, без `kws` и без `https://`: + - в приложении: **Настройки → Свой домен на Cloudflare** → `example.com`; + - в CLI: `tglock-cli --cf-proxy example.com` или `cf_proxy = ["example.com"]` в `tglock.toml`. + + В журнале появится строка `CF-прокси в списке маршрутов: kws1.example.com …`. + Если строка начинается с «не похож на имя домена» — домен вписан со схемой + или со слэшем. + +## Что учесть + +- Если у провайдера заблокирована сама сеть Cloudflare, этот маршрут не + поможет, как и Worker. +- Маршрут участвует в общем переборе вместе с Worker'ом: его проверка + начинается сразу после основного адреса Telegram, успешный запоминается. +- Шифрование Telegram не меняется: Cloudflare видит только зашифрованный + поток MTProto, как и провайдер. diff --git a/docs/CLOUDFLARE_WORKER.md b/docs/CLOUDFLARE_WORKER.md index b01a069..d4b581f 100644 --- a/docs/CLOUDFLARE_WORKER.md +++ b/docs/CLOUDFLARE_WORKER.md @@ -17,6 +17,8 @@ Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли. +Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов. + Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются. ## Что понадобится diff --git a/package-lock.json b/package-lock.json index 7b272ad..93c3fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tglock-ui", - "version": "2.0.0-beta.16", + "version": "2.0.0-beta.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tglock-ui", - "version": "2.0.0-beta.16", + "version": "2.0.0-beta.17", "dependencies": { "@tauri-apps/api": "^2.8.0" }, diff --git a/package.json b/package.json index 5d6f94d..4b2f149 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tglock-ui", "private": true, - "version": "2.0.0-beta.16", + "version": "2.0.0-beta.17", "type": "module", "scripts": { "test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js", diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 23335bf..90ad75c 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -55,6 +55,11 @@ struct Args { #[arg(long, value_name = "DOMAIN")] worker: Vec, + /// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на + /// адреса Telegram, docs/CF_PROXY.md). Можно повторять + #[arg(long, value_name = "DOMAIN")] + cf_proxy: Vec, + /// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5 #[arg(long)] allow_direct: bool, @@ -77,6 +82,7 @@ impl Args { lan: self.lan, allow_direct: self.allow_direct, worker: self.worker.clone(), + cf_proxy: self.cf_proxy.clone(), secret_file: self.secret_file.clone(), quiet: self.quiet, } @@ -168,6 +174,9 @@ async fn serve(args: Args) -> Result<(), String> { if !settings.workers.is_empty() { say(&format!("Резервные Worker-домены: {}", settings.workers)); } + if !settings.cf_proxies.is_empty() { + say(&format!("CF-прокси домены: {}", settings.cf_proxies)); + } } let server_stats = stats.clone(); @@ -338,6 +347,13 @@ mod tests { assert_eq!(settings.workers, "a.workers.dev,b.workers.dev"); } + #[test] + fn repeated_cf_proxy_flags_collapse_into_one_list() { + let settings = from_flags(&["--cf-proxy", "a.example", "--cf-proxy", "b.example"]); + assert_eq!(settings.cf_proxies, "a.example,b.example"); + assert!(from_flags(&[]).cf_proxies.is_empty()); + } + #[test] fn no_worker_flag_means_no_domains() { assert!(from_flags(&[]).workers.is_empty()); diff --git a/src/cli_settings.rs b/src/cli_settings.rs index 9c1facf..3a01c3c 100644 --- a/src/cli_settings.rs +++ b/src/cli_settings.rs @@ -33,6 +33,8 @@ pub struct FileConfig { pub lan: Option, pub allow_direct: Option, pub worker: Option>, + /// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md). + pub cf_proxy: Option>, pub secret: Option, pub secret_file: Option, pub quiet: Option, @@ -58,6 +60,7 @@ pub struct Overrides { pub lan: bool, pub allow_direct: bool, pub worker: Vec, + pub cf_proxy: Vec, pub secret_file: Option, pub quiet: bool, } @@ -79,6 +82,8 @@ pub struct Resolved { pub listen: ListenConfig, /// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`. pub workers: String, + /// Домены CF-прокси в том виде, в каком их ждёт `Stats::set_cf_proxy_domain`. + pub cf_proxies: String, pub secret: SecretSource, pub quiet: bool, } @@ -93,6 +98,7 @@ impl Resolved { SecretSource::Ephemeral => proxy::Stats::new(), }; stats.set_worker_domain(&self.workers); + stats.set_cf_proxy_domain(&self.cf_proxies); stats } } @@ -127,6 +133,11 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result { } else { cli.worker }; + let cf_proxies = if cli.cf_proxy.is_empty() { + file.cf_proxy.unwrap_or_default() + } else { + cli.cf_proxy + }; // Секрет из файла конфигурации важнее отдельного файла: если человек вписал // его сюда, значит хотел держать всё в одном месте. @@ -142,6 +153,7 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result { Ok(Resolved { listen, workers: workers.join(","), + cf_proxies: cf_proxies.join(","), secret, quiet: cli.quiet || file.quiet.unwrap_or(false), }) @@ -185,6 +197,7 @@ mod tests { port = 1443 allow_direct = true worker = ["a.workers.dev", "b.workers.dev"] + cf_proxy = ["example.com"] quiet = true "#, ) @@ -193,6 +206,7 @@ mod tests { assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443"); assert!(resolved.listen.allow_direct); assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev"); + assert_eq!(resolved.cf_proxies, "example.com"); assert!(resolved.quiet); } diff --git a/src/gui.rs b/src/gui.rs index 17a3f3c..1f23650 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -8,12 +8,17 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::{Manager, State}; use tauri_plugin_opener::OpenerExt; +// `default`: a settings file written before a field existed must keep the +// values it has. Without it the whole file failed to parse and silently fell +// back to defaults, dropping the user's Worker domain with it. #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", default)] struct Settings { lan_mode: bool, port: u16, worker_domain: String, + /// Свой домен на Cloudflare для CF-прокси (docs/CF_PROXY.md). + cf_proxy_domain: String, } impl Default for Settings { @@ -22,6 +27,7 @@ impl Default for Settings { lan_mode: false, port: proxy::DEFAULT_PORT, worker_domain: String::new(), + cf_proxy_domain: String::new(), } } } @@ -232,6 +238,7 @@ async fn start_proxy( // 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.stats.set_cf_proxy_domain(&settings.cf_proxy_domain); *state.active_port.lock().unwrap() = settings.port; *state.active_listen.lock().unwrap() = Some(listen); *state.started_at.lock().unwrap() = Some(Instant::now()); @@ -443,6 +450,17 @@ pub fn run() { mod tests { use super::*; + #[test] + fn a_settings_file_from_an_older_version_keeps_its_values() { + let old: Settings = + serde_json::from_str(r#"{"lanMode":true,"port":1443,"workerDomain":"a.workers.dev"}"#) + .unwrap(); + assert!(old.lan_mode); + assert_eq!(old.port, 1443); + assert_eq!(old.worker_domain, "a.workers.dev"); + assert_eq!(old.cf_proxy_domain, ""); + } + #[test] fn nothing_to_share_when_the_proxy_is_off_or_local() { assert_eq!( diff --git a/src/proxy.rs b/src/proxy.rs index ac07917..3dcf7d5 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -269,6 +269,26 @@ impl Stats { } } + /// Свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`, docs/CF_PROXY.md). + pub fn set_cf_proxy_domain(&self, domain: &str) { + let domains = domain + .split([',', ';', ' ']) + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .collect::>(); + let result = self.transport.set_cf_proxy_domains(&domains); + for rejected in &result.rejected { + self.note(format!( + "CF-прокси «{rejected}» не похож на имя домена — маршрут не добавлен. Нужен только домен, без https:// и без kws: example.com" + )); + } + for accepted in &result.accepted { + self.note(format!( + "CF-прокси в списке маршрутов: kws1.{accepted} … kws5.{accepted}, kws203.{accepted}" + )); + } + } + pub fn stop(&self) { if let Some(sender) = self.shutdown.lock().unwrap().take() { let _ = sender.send(true); @@ -919,7 +939,7 @@ async fn cdn_tcp_tunnel( stats, connected, dc, - "CDN TCP закрыт без ответа Telegram", + "MTProto TCP закрыт без ответа Telegram", )) }; } @@ -1745,7 +1765,7 @@ mod tests { assert!(stats .drain_events() .iter() - .any(|event| event.contains("CDN TCP закрыт без ответа"))); + .any(|event| event.contains("MTProto TCP закрыт без ответа"))); relay.await.unwrap(); stats.stop(); server.await.unwrap().unwrap(); diff --git a/src/transport.rs b/src/transport.rs index 625464e..9fe48f2 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -33,6 +33,10 @@ pub enum RouteKind { SystemDns, CloudflareWorker, TelegramTcp, + /// Свой домен на Cloudflare: записи `kwsN` указывают на адреса Telegram, + /// и Cloudflare сам доводит WebSocket до дата-центра. Worker и его лимиты + /// не нужны — так работает CF-прокси в Flowseal/tg-ws-proxy. + CloudflareProxy, } impl RouteKind { @@ -43,6 +47,7 @@ impl RouteKind { Self::SystemDns => 3, Self::CloudflareWorker => 4, Self::TelegramTcp => 5, + Self::CloudflareProxy => 6, } } @@ -53,6 +58,7 @@ impl RouteKind { 3 => Some(Self::SystemDns), 4 => Some(Self::CloudflareWorker), 5 => Some(Self::TelegramTcp), + 6 => Some(Self::CloudflareProxy), _ => None, } } @@ -64,7 +70,8 @@ impl RouteKind { Self::AlternateTelegramIp => "Запасной Telegram IP", Self::SystemDns => "Системный DNS", Self::CloudflareWorker => "Cloudflare Worker", - Self::TelegramTcp => "Telegram CDN TCP", + Self::TelegramTcp => "Telegram TCP (MTProto)", + Self::CloudflareProxy => "Cloudflare-прокси", } } } @@ -91,8 +98,13 @@ pub struct Route { impl Route { fn cdn_tcp() -> Self { + Self::telegram_tcp(CDN203_IP) + } + + /// Native obfuscated2 straight to a pinned Telegram address. + fn telegram_tcp(ip: &str) -> Self { Self { - connect_host: CDN203_IP.to_owned(), + connect_host: ip.to_owned(), websocket_host: String::new(), path: String::new(), kind: RouteKind::TelegramTcp, @@ -150,6 +162,7 @@ struct HealthState { pub struct TransportEngine { health: Mutex, worker_domains: Mutex>, + cf_proxy_domains: Mutex>, /// Сколько раз отдельный маршрут не ответил. /// /// Считается отдельно от `Stats::ws_failures`, который растёт только когда @@ -194,22 +207,16 @@ impl TransportEngine { /// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось /// от «воркера нет» (by-sonic/tglock#50). pub fn set_worker_domains(&self, domains: &[String]) -> WorkerDomains { - let mut accepted = Vec::new(); - let mut rejected = Vec::new(); - for domain in domains { - let trimmed = domain.trim(); - if trimmed.is_empty() { - continue; - } - let normalized = trimmed.to_ascii_lowercase(); - if !valid_domain(&normalized) { - rejected.push(trimmed.to_owned()); - } else if !accepted.contains(&normalized) { - accepted.push(normalized); - } - } - *self.worker_domains.lock().unwrap() = accepted.clone(); - WorkerDomains { accepted, rejected } + let result = split_domains(domains); + *self.worker_domains.lock().unwrap() = result.accepted.clone(); + result + } + + /// Задать свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`). + pub fn set_cf_proxy_domains(&self, domains: &[String]) -> WorkerDomains { + let result = split_domains(domains); + *self.cf_proxy_domains.lock().unwrap() = result.accepted.clone(); + result } pub async fn connect( @@ -235,47 +242,21 @@ impl TransportEngine { F: FnMut(Route) -> Fut, Fut: std::future::Future>, { - let mut candidates = self.ordered_candidates(key).into_iter().peekable(); - let mut pending = FuturesUnordered::new(); - let mut next_start = Instant::now(); + // Native TCP to an ordinary DC opens the moment the address is + // reachable, long before Telegram answers, so racing it would beat + // every slower working WebSocket. Like the TCP fallback in + // Flowseal/tg-ws-proxy it is tried only once every other route failed. + let (main, last_resort): (Vec<_>, Vec<_>) = self + .ordered_candidates(key) + .into_iter() + .partition(|route| !is_last_resort(route)); let mut errors = Vec::new(); - - while candidates.peek().is_some() || !pending.is_empty() { - if candidates.peek().is_some() - && pending.len() < MAX_CONNECTING - && (pending.is_empty() || Instant::now() >= next_start) + for candidates in [main, last_resort] { + if let Some(winner) = self + .race_candidates(key, candidates, &mut connect, &mut errors) + .await { - 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; - } - 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 => {} + return Ok(winner); } } @@ -305,6 +286,63 @@ impl TransportEngine { )) } + /// Race one tier of candidates; failures are appended to `errors`. + async fn race_candidates( + &self, + key: DcKey, + candidates: Vec, + connect: &mut F, + errors: &mut Vec, + ) -> Option<(T, ConnectedRoute)> + where + F: FnMut(Route) -> Fut, + Fut: std::future::Future>, + { + let mut candidates = candidates.into_iter().peekable(); + let mut pending = FuturesUnordered::new(); + let mut next_start = Instant::now(); + + 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; + } + 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 Some((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 => {} + } + } + None + } + fn route_available(&self, route: &Route) -> bool { self.health .lock() @@ -333,14 +371,16 @@ impl TransportEngine { candidates.sort_by_key(|route| { let preferred_rank = u8::from(preferred != Some(route)); let kind_rank = match route.kind { - RouteKind::TelegramTcp => 0, + RouteKind::TelegramTcp if route.connect_host == CDN203_IP => 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 + // The operator explicitly configured these independent paths. + // Start them after the primary IP, before more potentially // blocked Telegram addresses consume the concurrency budget. - RouteKind::CloudflareWorker => 1, + RouteKind::CloudflareWorker | RouteKind::CloudflareProxy => 1, RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2, RouteKind::SystemDns => 3, + // Last resort, raced separately; see race_connections(). + RouteKind::TelegramTcp => 4, }; (preferred_rank, kind_rank) }); @@ -358,6 +398,15 @@ impl TransportEngine { } let mut routes = routes_for_dc(key.dc, key.media); + for domain in self.cf_proxy_domains.lock().unwrap().iter() { + let host = cf_proxy_host(key.dc, domain); + routes.push(Route::https( + host.clone(), + host, + "/apiws".to_owned(), + RouteKind::CloudflareProxy, + )); + } let Some(path) = worker_path(key.dc) else { return routes; }; @@ -423,6 +472,19 @@ pub(crate) fn worker_path(dc: u16) -> Option { Some(format!("/apiws?dst={}&dc={}", destination, dc)) } +/// Host of a CF-proxy domain for a data centre: `kws.`, with DNS +/// records the operator points at the Telegram addresses (docs/CF_PROXY.md). +/// CDN203 keeps its own record; it must never be folded into DC2. +pub(crate) fn cf_proxy_host(dc: u16, domain: &str) -> String { + format!("kws{}.{}", dc, domain) +} + +/// Native TCP to an ordinary data centre — every pinned address except the +/// CDN, whose native transport is its primary route. +fn is_last_resort(route: &Route) -> bool { + route.kind == RouteKind::TelegramTcp && route.connect_host != CDN203_IP +} + /// Every address a Worker may be asked to reach, so a deployment can refuse /// anything else instead of becoming an open TCP proxy. pub fn worker_allowed_destinations() -> Vec<&'static str> { @@ -502,6 +564,16 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec { )); } } + // Where the WebSocket frontends are filtered but the address itself is + // reachable, plain MTProto to the DC's primary address still works — + // measured from a Russian ISP where TLS to .51/.91/.50/.100/.5 was cut + // while native TCP to each of them answered req_pq (main and media). + // 149.154.167.220 is a WebSocket frontend only and does not speak native + // MTProto, so it gets no TCP route. Raced only after everything above + // failed (race_connections). + if dc != 203 { + routes.push(Route::telegram_tcp(ips[0])); + } routes } @@ -539,7 +611,9 @@ async fn connect_route_with_config( // 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()); + return Err( + "native MTProto TCP разрешён только до закреплённых адресов Telegram".to_owned(), + ); } let tcp = tokio::time::timeout( CONNECT_TIMEOUT, @@ -601,13 +675,32 @@ fn allowed_tcp_route(route: &Route) -> bool { if route.connect_host == "127.0.0.1" && !route.secure { return true; } - route.connect_host == CDN203_IP + worker_allowed_destinations().contains(&route.connect_host.as_str()) && route.port == HTTPS_PORT && route.websocket_host.is_empty() && route.path.is_empty() && !route.secure } +/// Нормализовать список доменов, отделив непохожие на имя хоста. +fn split_domains(domains: &[String]) -> WorkerDomains { + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + for domain in domains { + let trimmed = domain.trim(); + if trimmed.is_empty() { + continue; + } + let normalized = trimmed.to_ascii_lowercase(); + if !valid_domain(&normalized) { + rejected.push(trimmed.to_owned()); + } else if !accepted.contains(&normalized) { + accepted.push(normalized); + } + } + WorkerDomains { accepted, rejected } +} + fn valid_domain(domain: &str) -> bool { if domain.is_empty() || domain.len() > 253 || !domain.contains('.') { return false; @@ -714,9 +807,10 @@ mod tests { } #[test] - fn production_websockets_use_tls_and_native_tcp_is_restricted_to_cdn203() { + fn production_websockets_use_tls_and_native_tcp_only_reaches_pinned_telegram_ips() { let engine = TransportEngine::new(); engine.set_worker_domains(&["fallback.workers.dev".to_owned()]); + engine.set_cf_proxy_domains(&["example.com".to_owned()]); for dc in [1, 2, 3, 4, 5, 203] { for media in [false, true] { let routes = engine.routes_for_key(DcKey { dc, media }); @@ -724,8 +818,10 @@ mod tests { for route in routes { assert_eq!(route.port, 443, "{route:?}"); if route.kind == RouteKind::TelegramTcp { - assert_eq!(dc, 203); - assert_eq!(route.connect_host, CDN203_IP); + assert!( + telegram_ips(dc).contains(&route.connect_host.as_str()), + "{route:?}" + ); assert!(allowed_tcp_route(&route)); } else { assert!(route.secure, "{route:?}"); @@ -735,6 +831,98 @@ mod tests { } } + #[test] + fn native_tcp_to_an_ordinary_dc_is_a_last_resort_but_cdn203_leads() { + let engine = TransportEngine::new(); + engine.set_cf_proxy_domains(&["example.com".to_owned()]); + for dc in [1, 2, 3, 4, 5] { + let ordered = engine.ordered_candidates(DcKey { dc, media: true }); + let first_tcp = ordered + .iter() + .position(|route| route.kind == RouteKind::TelegramTcp) + .expect("every DC has a native TCP fallback"); + assert!( + ordered[first_tcp..].iter().all(is_last_resort), + "DC{dc}: {ordered:?}" + ); + assert!(ordered[..first_tcp] + .iter() + .all(|route| !is_last_resort(route))); + } + let cdn = engine.ordered_candidates(DcKey { + dc: 203, + media: true, + }); + assert_eq!(cdn[0], Route::cdn_tcp()); + assert!(!is_last_resort(&cdn[0])); + } + + #[tokio::test] + async fn native_tcp_is_dialed_only_after_every_other_route_failed() { + use std::sync::Mutex as StdMutex; + let key = DcKey { dc: 2, media: true }; + + // A slow but working WebSocket wins; TCP is never dialed. + let engine = TransportEngine::new(); + let dialed = Arc::new(StdMutex::new(Vec::new())); + let log = dialed.clone(); + let (_, connected) = engine + .race_connections(key, move |route: Route| { + log.lock().unwrap().push(route.kind); + async move { + if route.kind == RouteKind::TelegramIp { + tokio::time::sleep(Duration::from_millis(300)).await; + Ok(()) + } else if route.kind == RouteKind::TelegramTcp { + Ok(()) + } else { + Err("blocked".to_owned()) + } + } + }) + .await + .unwrap(); + assert_eq!(connected.route.kind, RouteKind::TelegramIp); + assert!(!dialed.lock().unwrap().contains(&RouteKind::TelegramTcp)); + + // Every WebSocket fails: the pinned TCP route carries the tunnel. + let engine = TransportEngine::new(); + let (_, connected) = engine + .race_connections(key, |route: Route| async move { + if route.kind == RouteKind::TelegramTcp { + Ok(()) + } else { + Err("blocked".to_owned()) + } + }) + .await + .unwrap(); + assert_eq!(connected.route.kind, RouteKind::TelegramTcp); + assert!(telegram_ips(2).contains(&connected.route.connect_host.as_str())); + } + + #[test] + fn cf_proxy_domains_become_kws_routes_and_keep_cdn203_separate() { + let engine = TransportEngine::new(); + let result = engine.set_cf_proxy_domains(&[ + " Example.COM ".to_owned(), + "https://bad.example/".to_owned(), + ]); + assert_eq!(result.accepted, ["example.com"]); + assert_eq!(result.rejected, ["https://bad.example/"]); + for (dc, host) in [(2, "kws2.example.com"), (203, "kws203.example.com")] { + let routes = engine.routes_for_key(DcKey { dc, media: true }); + let proxy = routes + .iter() + .find(|route| route.kind == RouteKind::CloudflareProxy) + .expect("CF proxy route"); + assert_eq!(proxy.connect_host, host); + assert_eq!(proxy.websocket_host, host); + assert_eq!(proxy.path, "/apiws"); + assert!(proxy.secure); + } + } + #[test] fn every_data_center_offers_a_pinned_ip_and_a_dns_route() { for dc in [1, 2, 3, 4, 5] { @@ -1270,11 +1458,16 @@ mod tests { 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!( + error.contains("только до закреплённых адресов Telegram"), + "{error}" + ); + assert!(routes_for_dc(999, false).is_empty()); + for dc in [1, 2, 3, 4, 5] { assert!(routes_for_dc(dc, false) .iter() - .all(|route| route.kind != RouteKind::TelegramTcp)); + .filter(|route| route.kind == RouteKind::TelegramTcp) + .all(|route| route.connect_host == telegram_ips(dc)[0])); } } diff --git a/tauri.conf.json b/tauri.conf.json index d0937cc..4d74d9d 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "TGLock", - "version": "2.0.0-beta.16", + "version": "2.0.0-beta.17", "identifier": "com.bysonic.tglock", "mainBinaryName": "tglock", "build": { diff --git a/tglock.example.toml b/tglock.example.toml index 92616a9..c5ec0ba 100644 --- a/tglock.example.toml +++ b/tglock.example.toml @@ -33,6 +33,10 @@ # docs/CLOUDFLARE_WORKER.md # worker = ["my-name.workers.dev"] +# Свой домен на Cloudflare (CF-прокси): записи kws1…kws5 и kws203 указывают +# на адреса Telegram, Worker не нужен. Настройка описана в docs/CF_PROXY.md +# cf_proxy = ["example.com"] + # Пропускать и не-Telegram адреса. # На loopback это включено само. На сетевом адресе означает открытый # SOCKS5-прокси для всего интернета — включайте осознанно. diff --git a/ui/main.ts b/ui/main.ts index c232669..dc58574 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -34,6 +34,7 @@ type Settings = { lanMode: boolean; port: number; workerDomain: string; + cfProxyDomain: string; }; const root = document.querySelector("#app")!; @@ -55,7 +56,7 @@ let status: Status = { shareAddress: null, logs: [], }; -let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" }; +let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" }; let busy = false; let toastTimer: number | undefined; let toastMessage = ""; @@ -232,6 +233,20 @@ function renderSettings(): void {

Необязательно. Используется как резервный маршрут.

+
+ + +

Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.

+
+