mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 07:11:56 +03:00
feat: запасной MTProto TCP и CF-прокси через свой домен (по образцу Flowseal/tg-ws-proxy); 2.0.0-beta.17 (#73)
* feat: native MTProto TCP fallback and CF-proxy domains, after Flowseal/tg-ws-proxy; 2.0.0-beta.17 Two routes Flowseal/tg-ws-proxy has and TGLock lacked. Native TCP fallback. When every WebSocket route for a DC fails, TGLock now tries plain obfuscated2 MTProto to the DC's primary address, which the CDN203 route already used. Measured from a Russian ISP: TLS to .51, .91, .50, .100 and .5 was cut, while native TCP to each answered req_pq_multi, for main and media DCs alike. 149.154.167.220 is a WebSocket frontend only and gets no TCP route. TCP opens before Telegram answers, so it is raced only after all other routes failed; it can never displace a slower working WebSocket. Raw TCP stays restricted to the pinned Telegram addresses. CF proxy. A user's own Cloudflare domain with kws1..kws5/kws203 records pointing at Telegram proxies the WebSocket without a Worker or its limits (--cf-proxy, cf_proxy in tglock.toml, a GUI field; docs/CF_PROXY.md). GUI settings now deserialize with serde(default): a file written before a field existed used to fail to parse and silently reset to defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: babin <Thartewerner536e@engineer.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -3822,7 +3822,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.16"
|
version = "2.0.0-beta.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cipher",
|
"cipher",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.16"
|
version = "2.0.0-beta.17"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
description = "Telegram unblock via local WebSocket tunnel"
|
description = "Telegram unblock via local WebSocket tunnel"
|
||||||
|
|||||||
@@ -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, как и провайдер.
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
|
||||||
|
|
||||||
|
Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов.
|
||||||
|
|
||||||
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
|
||||||
|
|
||||||
## Что понадобится
|
## Что понадобится
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ struct Args {
|
|||||||
#[arg(long, value_name = "DOMAIN")]
|
#[arg(long, value_name = "DOMAIN")]
|
||||||
worker: Vec<String>,
|
worker: Vec<String>,
|
||||||
|
|
||||||
|
/// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на
|
||||||
|
/// адреса Telegram, docs/CF_PROXY.md). Можно повторять
|
||||||
|
#[arg(long, value_name = "DOMAIN")]
|
||||||
|
cf_proxy: Vec<String>,
|
||||||
|
|
||||||
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
|
/// Проксировать и не-Telegram адреса. На сетевом адресе это открытый SOCKS5
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
allow_direct: bool,
|
allow_direct: bool,
|
||||||
@@ -77,6 +82,7 @@ impl Args {
|
|||||||
lan: self.lan,
|
lan: self.lan,
|
||||||
allow_direct: self.allow_direct,
|
allow_direct: self.allow_direct,
|
||||||
worker: self.worker.clone(),
|
worker: self.worker.clone(),
|
||||||
|
cf_proxy: self.cf_proxy.clone(),
|
||||||
secret_file: self.secret_file.clone(),
|
secret_file: self.secret_file.clone(),
|
||||||
quiet: self.quiet,
|
quiet: self.quiet,
|
||||||
}
|
}
|
||||||
@@ -168,6 +174,9 @@ async fn serve(args: Args) -> Result<(), String> {
|
|||||||
if !settings.workers.is_empty() {
|
if !settings.workers.is_empty() {
|
||||||
say(&format!("Резервные Worker-домены: {}", settings.workers));
|
say(&format!("Резервные Worker-домены: {}", settings.workers));
|
||||||
}
|
}
|
||||||
|
if !settings.cf_proxies.is_empty() {
|
||||||
|
say(&format!("CF-прокси домены: {}", settings.cf_proxies));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_stats = stats.clone();
|
let server_stats = stats.clone();
|
||||||
@@ -338,6 +347,13 @@ mod tests {
|
|||||||
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
|
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]
|
#[test]
|
||||||
fn no_worker_flag_means_no_domains() {
|
fn no_worker_flag_means_no_domains() {
|
||||||
assert!(from_flags(&[]).workers.is_empty());
|
assert!(from_flags(&[]).workers.is_empty());
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub struct FileConfig {
|
|||||||
pub lan: Option<bool>,
|
pub lan: Option<bool>,
|
||||||
pub allow_direct: Option<bool>,
|
pub allow_direct: Option<bool>,
|
||||||
pub worker: Option<Vec<String>>,
|
pub worker: Option<Vec<String>>,
|
||||||
|
/// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md).
|
||||||
|
pub cf_proxy: Option<Vec<String>>,
|
||||||
pub secret: Option<String>,
|
pub secret: Option<String>,
|
||||||
pub secret_file: Option<PathBuf>,
|
pub secret_file: Option<PathBuf>,
|
||||||
pub quiet: Option<bool>,
|
pub quiet: Option<bool>,
|
||||||
@@ -58,6 +60,7 @@ pub struct Overrides {
|
|||||||
pub lan: bool,
|
pub lan: bool,
|
||||||
pub allow_direct: bool,
|
pub allow_direct: bool,
|
||||||
pub worker: Vec<String>,
|
pub worker: Vec<String>,
|
||||||
|
pub cf_proxy: Vec<String>,
|
||||||
pub secret_file: Option<PathBuf>,
|
pub secret_file: Option<PathBuf>,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,8 @@ pub struct Resolved {
|
|||||||
pub listen: ListenConfig,
|
pub listen: ListenConfig,
|
||||||
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
|
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
|
||||||
pub workers: String,
|
pub workers: String,
|
||||||
|
/// Домены CF-прокси в том виде, в каком их ждёт `Stats::set_cf_proxy_domain`.
|
||||||
|
pub cf_proxies: String,
|
||||||
pub secret: SecretSource,
|
pub secret: SecretSource,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
}
|
}
|
||||||
@@ -93,6 +98,7 @@ impl Resolved {
|
|||||||
SecretSource::Ephemeral => proxy::Stats::new(),
|
SecretSource::Ephemeral => proxy::Stats::new(),
|
||||||
};
|
};
|
||||||
stats.set_worker_domain(&self.workers);
|
stats.set_worker_domain(&self.workers);
|
||||||
|
stats.set_cf_proxy_domain(&self.cf_proxies);
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +133,11 @@ pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
|
|||||||
} else {
|
} else {
|
||||||
cli.worker
|
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<Resolved, String> {
|
|||||||
Ok(Resolved {
|
Ok(Resolved {
|
||||||
listen,
|
listen,
|
||||||
workers: workers.join(","),
|
workers: workers.join(","),
|
||||||
|
cf_proxies: cf_proxies.join(","),
|
||||||
secret,
|
secret,
|
||||||
quiet: cli.quiet || file.quiet.unwrap_or(false),
|
quiet: cli.quiet || file.quiet.unwrap_or(false),
|
||||||
})
|
})
|
||||||
@@ -185,6 +197,7 @@ mod tests {
|
|||||||
port = 1443
|
port = 1443
|
||||||
allow_direct = true
|
allow_direct = true
|
||||||
worker = ["a.workers.dev", "b.workers.dev"]
|
worker = ["a.workers.dev", "b.workers.dev"]
|
||||||
|
cf_proxy = ["example.com"]
|
||||||
quiet = true
|
quiet = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -193,6 +206,7 @@ mod tests {
|
|||||||
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
|
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
|
||||||
assert!(resolved.listen.allow_direct);
|
assert!(resolved.listen.allow_direct);
|
||||||
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
|
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
|
||||||
|
assert_eq!(resolved.cf_proxies, "example.com");
|
||||||
assert!(resolved.quiet);
|
assert!(resolved.quiet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -8,12 +8,17 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
|||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
struct Settings {
|
struct Settings {
|
||||||
lan_mode: bool,
|
lan_mode: bool,
|
||||||
port: u16,
|
port: u16,
|
||||||
worker_domain: String,
|
worker_domain: String,
|
||||||
|
/// Свой домен на Cloudflare для CF-прокси (docs/CF_PROXY.md).
|
||||||
|
cf_proxy_domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -22,6 +27,7 @@ impl Default for Settings {
|
|||||||
lan_mode: false,
|
lan_mode: false,
|
||||||
port: proxy::DEFAULT_PORT,
|
port: proxy::DEFAULT_PORT,
|
||||||
worker_domain: String::new(),
|
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.
|
// A rejected foreground-service start must not leave a hidden proxy running.
|
||||||
set_foreground_service(&app, true)?;
|
set_foreground_service(&app, true)?;
|
||||||
state.stats.set_worker_domain(&settings.worker_domain);
|
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_port.lock().unwrap() = settings.port;
|
||||||
*state.active_listen.lock().unwrap() = Some(listen);
|
*state.active_listen.lock().unwrap() = Some(listen);
|
||||||
*state.started_at.lock().unwrap() = Some(Instant::now());
|
*state.started_at.lock().unwrap() = Some(Instant::now());
|
||||||
@@ -443,6 +450,17 @@ pub fn run() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn nothing_to_share_when_the_proxy_is_off_or_local() {
|
fn nothing_to_share_when_the_proxy_is_off_or_local() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+22
-2
@@ -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::<Vec<_>>();
|
||||||
|
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) {
|
pub fn stop(&self) {
|
||||||
if let Some(sender) = self.shutdown.lock().unwrap().take() {
|
if let Some(sender) = self.shutdown.lock().unwrap().take() {
|
||||||
let _ = sender.send(true);
|
let _ = sender.send(true);
|
||||||
@@ -919,7 +939,7 @@ async fn cdn_tcp_tunnel(
|
|||||||
stats,
|
stats,
|
||||||
connected,
|
connected,
|
||||||
dc,
|
dc,
|
||||||
"CDN TCP закрыт без ответа Telegram",
|
"MTProto TCP закрыт без ответа Telegram",
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1745,7 +1765,7 @@ mod tests {
|
|||||||
assert!(stats
|
assert!(stats
|
||||||
.drain_events()
|
.drain_events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.contains("CDN TCP закрыт без ответа")));
|
.any(|event| event.contains("MTProto TCP закрыт без ответа")));
|
||||||
relay.await.unwrap();
|
relay.await.unwrap();
|
||||||
stats.stop();
|
stats.stop();
|
||||||
server.await.unwrap().unwrap();
|
server.await.unwrap().unwrap();
|
||||||
|
|||||||
+262
-69
@@ -33,6 +33,10 @@ pub enum RouteKind {
|
|||||||
SystemDns,
|
SystemDns,
|
||||||
CloudflareWorker,
|
CloudflareWorker,
|
||||||
TelegramTcp,
|
TelegramTcp,
|
||||||
|
/// Свой домен на Cloudflare: записи `kwsN` указывают на адреса Telegram,
|
||||||
|
/// и Cloudflare сам доводит WebSocket до дата-центра. Worker и его лимиты
|
||||||
|
/// не нужны — так работает CF-прокси в Flowseal/tg-ws-proxy.
|
||||||
|
CloudflareProxy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteKind {
|
impl RouteKind {
|
||||||
@@ -43,6 +47,7 @@ impl RouteKind {
|
|||||||
Self::SystemDns => 3,
|
Self::SystemDns => 3,
|
||||||
Self::CloudflareWorker => 4,
|
Self::CloudflareWorker => 4,
|
||||||
Self::TelegramTcp => 5,
|
Self::TelegramTcp => 5,
|
||||||
|
Self::CloudflareProxy => 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +58,7 @@ impl RouteKind {
|
|||||||
3 => Some(Self::SystemDns),
|
3 => Some(Self::SystemDns),
|
||||||
4 => Some(Self::CloudflareWorker),
|
4 => Some(Self::CloudflareWorker),
|
||||||
5 => Some(Self::TelegramTcp),
|
5 => Some(Self::TelegramTcp),
|
||||||
|
6 => Some(Self::CloudflareProxy),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,8 @@ impl RouteKind {
|
|||||||
Self::AlternateTelegramIp => "Запасной Telegram IP",
|
Self::AlternateTelegramIp => "Запасной Telegram IP",
|
||||||
Self::SystemDns => "Системный DNS",
|
Self::SystemDns => "Системный DNS",
|
||||||
Self::CloudflareWorker => "Cloudflare Worker",
|
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 {
|
impl Route {
|
||||||
fn cdn_tcp() -> Self {
|
fn cdn_tcp() -> Self {
|
||||||
|
Self::telegram_tcp(CDN203_IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native obfuscated2 straight to a pinned Telegram address.
|
||||||
|
fn telegram_tcp(ip: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
connect_host: CDN203_IP.to_owned(),
|
connect_host: ip.to_owned(),
|
||||||
websocket_host: String::new(),
|
websocket_host: String::new(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
kind: RouteKind::TelegramTcp,
|
kind: RouteKind::TelegramTcp,
|
||||||
@@ -150,6 +162,7 @@ struct HealthState {
|
|||||||
pub struct TransportEngine {
|
pub struct TransportEngine {
|
||||||
health: Mutex<HealthState>,
|
health: Mutex<HealthState>,
|
||||||
worker_domains: Mutex<Vec<String>>,
|
worker_domains: Mutex<Vec<String>>,
|
||||||
|
cf_proxy_domains: Mutex<Vec<String>>,
|
||||||
/// Сколько раз отдельный маршрут не ответил.
|
/// Сколько раз отдельный маршрут не ответил.
|
||||||
///
|
///
|
||||||
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
|
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
|
||||||
@@ -194,22 +207,16 @@ impl TransportEngine {
|
|||||||
/// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось
|
/// проверку, маршрут не появлялся, и «воркер настроен» ничем не отличалось
|
||||||
/// от «воркера нет» (by-sonic/tglock#50).
|
/// от «воркера нет» (by-sonic/tglock#50).
|
||||||
pub fn set_worker_domains(&self, domains: &[String]) -> WorkerDomains {
|
pub fn set_worker_domains(&self, domains: &[String]) -> WorkerDomains {
|
||||||
let mut accepted = Vec::new();
|
let result = split_domains(domains);
|
||||||
let mut rejected = Vec::new();
|
*self.worker_domains.lock().unwrap() = result.accepted.clone();
|
||||||
for domain in domains {
|
result
|
||||||
let trimmed = domain.trim();
|
}
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
/// Задать свои домены на Cloudflare для CF-прокси (`kwsN.<домен>`).
|
||||||
}
|
pub fn set_cf_proxy_domains(&self, domains: &[String]) -> WorkerDomains {
|
||||||
let normalized = trimmed.to_ascii_lowercase();
|
let result = split_domains(domains);
|
||||||
if !valid_domain(&normalized) {
|
*self.cf_proxy_domains.lock().unwrap() = result.accepted.clone();
|
||||||
rejected.push(trimmed.to_owned());
|
result
|
||||||
} else if !accepted.contains(&normalized) {
|
|
||||||
accepted.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*self.worker_domains.lock().unwrap() = accepted.clone();
|
|
||||||
WorkerDomains { accepted, rejected }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
@@ -235,47 +242,21 @@ impl TransportEngine {
|
|||||||
F: FnMut(Route) -> Fut,
|
F: FnMut(Route) -> Fut,
|
||||||
Fut: std::future::Future<Output = Result<T, String>>,
|
Fut: std::future::Future<Output = Result<T, String>>,
|
||||||
{
|
{
|
||||||
let mut candidates = self.ordered_candidates(key).into_iter().peekable();
|
// Native TCP to an ordinary DC opens the moment the address is
|
||||||
let mut pending = FuturesUnordered::new();
|
// reachable, long before Telegram answers, so racing it would beat
|
||||||
let mut next_start = Instant::now();
|
// 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();
|
let mut errors = Vec::new();
|
||||||
|
for candidates in [main, last_resort] {
|
||||||
while candidates.peek().is_some() || !pending.is_empty() {
|
if let Some(winner) = self
|
||||||
if candidates.peek().is_some()
|
.race_candidates(key, candidates, &mut connect, &mut errors)
|
||||||
&& pending.len() < MAX_CONNECTING
|
.await
|
||||||
&& (pending.is_empty() || Instant::now() >= next_start)
|
|
||||||
{
|
{
|
||||||
let route = candidates.next().unwrap();
|
return Ok(winner);
|
||||||
// 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 => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +286,63 @@ impl TransportEngine {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Race one tier of candidates; failures are appended to `errors`.
|
||||||
|
async fn race_candidates<T, F, Fut>(
|
||||||
|
&self,
|
||||||
|
key: DcKey,
|
||||||
|
candidates: Vec<Route>,
|
||||||
|
connect: &mut F,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
) -> Option<(T, ConnectedRoute)>
|
||||||
|
where
|
||||||
|
F: FnMut(Route) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T, String>>,
|
||||||
|
{
|
||||||
|
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 {
|
fn route_available(&self, route: &Route) -> bool {
|
||||||
self.health
|
self.health
|
||||||
.lock()
|
.lock()
|
||||||
@@ -333,14 +371,16 @@ impl TransportEngine {
|
|||||||
candidates.sort_by_key(|route| {
|
candidates.sort_by_key(|route| {
|
||||||
let preferred_rank = u8::from(preferred != Some(route));
|
let preferred_rank = u8::from(preferred != Some(route));
|
||||||
let kind_rank = match route.kind {
|
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,
|
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
|
||||||
// The operator explicitly configured this independent path.
|
// The operator explicitly configured these independent paths.
|
||||||
// Start it after the primary IP, before more potentially
|
// Start them after the primary IP, before more potentially
|
||||||
// blocked Telegram addresses consume the concurrency budget.
|
// blocked Telegram addresses consume the concurrency budget.
|
||||||
RouteKind::CloudflareWorker => 1,
|
RouteKind::CloudflareWorker | RouteKind::CloudflareProxy => 1,
|
||||||
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
|
RouteKind::TelegramIp | RouteKind::AlternateTelegramIp => 2,
|
||||||
RouteKind::SystemDns => 3,
|
RouteKind::SystemDns => 3,
|
||||||
|
// Last resort, raced separately; see race_connections().
|
||||||
|
RouteKind::TelegramTcp => 4,
|
||||||
};
|
};
|
||||||
(preferred_rank, kind_rank)
|
(preferred_rank, kind_rank)
|
||||||
});
|
});
|
||||||
@@ -358,6 +398,15 @@ impl TransportEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut routes = routes_for_dc(key.dc, key.media);
|
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 {
|
let Some(path) = worker_path(key.dc) else {
|
||||||
return routes;
|
return routes;
|
||||||
};
|
};
|
||||||
@@ -423,6 +472,19 @@ pub(crate) fn worker_path(dc: u16) -> Option<String> {
|
|||||||
Some(format!("/apiws?dst={}&dc={}", destination, dc))
|
Some(format!("/apiws?dst={}&dc={}", destination, dc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host of a CF-proxy domain for a data centre: `kws<dc>.<domain>`, 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
|
/// Every address a Worker may be asked to reach, so a deployment can refuse
|
||||||
/// anything else instead of becoming an open TCP proxy.
|
/// anything else instead of becoming an open TCP proxy.
|
||||||
pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
pub fn worker_allowed_destinations() -> Vec<&'static str> {
|
||||||
@@ -502,6 +564,16 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +611,9 @@ async fn connect_route_with_config(
|
|||||||
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
|
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
|
||||||
// hatch. Reject malformed raw routes before dialing anything.
|
// hatch. Reject malformed raw routes before dialing anything.
|
||||||
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
|
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(
|
let tcp = tokio::time::timeout(
|
||||||
CONNECT_TIMEOUT,
|
CONNECT_TIMEOUT,
|
||||||
@@ -601,13 +675,32 @@ fn allowed_tcp_route(route: &Route) -> bool {
|
|||||||
if route.connect_host == "127.0.0.1" && !route.secure {
|
if route.connect_host == "127.0.0.1" && !route.secure {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
route.connect_host == CDN203_IP
|
worker_allowed_destinations().contains(&route.connect_host.as_str())
|
||||||
&& route.port == HTTPS_PORT
|
&& route.port == HTTPS_PORT
|
||||||
&& route.websocket_host.is_empty()
|
&& route.websocket_host.is_empty()
|
||||||
&& route.path.is_empty()
|
&& route.path.is_empty()
|
||||||
&& !route.secure
|
&& !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 {
|
fn valid_domain(domain: &str) -> bool {
|
||||||
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
|
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
|
||||||
return false;
|
return false;
|
||||||
@@ -714,9 +807,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let engine = TransportEngine::new();
|
||||||
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
|
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 dc in [1, 2, 3, 4, 5, 203] {
|
||||||
for media in [false, true] {
|
for media in [false, true] {
|
||||||
let routes = engine.routes_for_key(DcKey { dc, media });
|
let routes = engine.routes_for_key(DcKey { dc, media });
|
||||||
@@ -724,8 +818,10 @@ mod tests {
|
|||||||
for route in routes {
|
for route in routes {
|
||||||
assert_eq!(route.port, 443, "{route:?}");
|
assert_eq!(route.port, 443, "{route:?}");
|
||||||
if route.kind == RouteKind::TelegramTcp {
|
if route.kind == RouteKind::TelegramTcp {
|
||||||
assert_eq!(dc, 203);
|
assert!(
|
||||||
assert_eq!(route.connect_host, CDN203_IP);
|
telegram_ips(dc).contains(&route.connect_host.as_str()),
|
||||||
|
"{route:?}"
|
||||||
|
);
|
||||||
assert!(allowed_tcp_route(&route));
|
assert!(allowed_tcp_route(&route));
|
||||||
} else {
|
} else {
|
||||||
assert!(route.secure, "{route:?}");
|
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]
|
#[test]
|
||||||
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
|
fn every_data_center_offers_a_pinned_ip_and_a_dns_route() {
|
||||||
for dc in [1, 2, 3, 4, 5] {
|
for dc in [1, 2, 3, 4, 5] {
|
||||||
@@ -1270,11 +1458,16 @@ mod tests {
|
|||||||
let mut route = Route::cdn_tcp();
|
let mut route = Route::cdn_tcp();
|
||||||
route.connect_host = "127.0.0.2".to_owned();
|
route.connect_host = "127.0.0.2".to_owned();
|
||||||
let error = connect_route(&route).await.unwrap_err();
|
let error = connect_route(&route).await.unwrap_err();
|
||||||
assert!(error.contains("только для закреплённого CDN203"), "{error}");
|
assert!(
|
||||||
for dc in [1, 2, 3, 4, 5, 999] {
|
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)
|
assert!(routes_for_dc(dc, false)
|
||||||
.iter()
|
.iter()
|
||||||
.all(|route| route.kind != RouteKind::TelegramTcp));
|
.filter(|route| route.kind == RouteKind::TelegramTcp)
|
||||||
|
.all(|route| route.connect_host == telegram_ips(dc)[0]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "TGLock",
|
"productName": "TGLock",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.17",
|
||||||
"identifier": "com.bysonic.tglock",
|
"identifier": "com.bysonic.tglock",
|
||||||
"mainBinaryName": "tglock",
|
"mainBinaryName": "tglock",
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
# docs/CLOUDFLARE_WORKER.md
|
# docs/CLOUDFLARE_WORKER.md
|
||||||
# worker = ["my-name.workers.dev"]
|
# worker = ["my-name.workers.dev"]
|
||||||
|
|
||||||
|
# Свой домен на Cloudflare (CF-прокси): записи kws1…kws5 и kws203 указывают
|
||||||
|
# на адреса Telegram, Worker не нужен. Настройка описана в docs/CF_PROXY.md
|
||||||
|
# cf_proxy = ["example.com"]
|
||||||
|
|
||||||
# Пропускать и не-Telegram адреса.
|
# Пропускать и не-Telegram адреса.
|
||||||
# На loopback это включено само. На сетевом адресе означает открытый
|
# На loopback это включено само. На сетевом адресе означает открытый
|
||||||
# SOCKS5-прокси для всего интернета — включайте осознанно.
|
# SOCKS5-прокси для всего интернета — включайте осознанно.
|
||||||
|
|||||||
+20
-3
@@ -34,6 +34,7 @@ type Settings = {
|
|||||||
lanMode: boolean;
|
lanMode: boolean;
|
||||||
port: number;
|
port: number;
|
||||||
workerDomain: string;
|
workerDomain: string;
|
||||||
|
cfProxyDomain: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = document.querySelector<HTMLDivElement>("#app")!;
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||||||
@@ -55,7 +56,7 @@ let status: Status = {
|
|||||||
shareAddress: null,
|
shareAddress: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
};
|
};
|
||||||
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
|
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "", cfProxyDomain: "" };
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let toastMessage = "";
|
let toastMessage = "";
|
||||||
@@ -232,6 +233,20 @@ function renderSettings(): void {
|
|||||||
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
|
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-card">
|
||||||
|
<label class="field-label" for="cf-proxy">Свой домен на Cloudflare</label>
|
||||||
|
<input
|
||||||
|
id="cf-proxy"
|
||||||
|
name="cf-proxy"
|
||||||
|
type="text"
|
||||||
|
value="${escapeHtml(settings.cfProxyDomain)}"
|
||||||
|
placeholder="example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
${status.running ? "disabled" : ""}
|
||||||
|
/>
|
||||||
|
<p class="field-hint">Необязательно. CF-прокси без Worker: записи kws1…kws5 и kws203 на адреса Telegram.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-list">
|
<div class="setting-list">
|
||||||
<label class="setting-row" for="lan-mode">
|
<label class="setting-row" for="lan-mode">
|
||||||
<span>
|
<span>
|
||||||
@@ -403,9 +418,10 @@ async function saveSettings(event: Event): Promise<void> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busy || status.running) return;
|
if (busy || status.running) return;
|
||||||
const worker = document.querySelector<HTMLInputElement>("#worker");
|
const worker = document.querySelector<HTMLInputElement>("#worker");
|
||||||
|
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
|
||||||
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
|
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
|
||||||
const port = document.querySelector<HTMLInputElement>("#port");
|
const port = document.querySelector<HTMLInputElement>("#port");
|
||||||
if (!worker || !lanMode || !port) return;
|
if (!worker || !cfProxy || !lanMode || !port) return;
|
||||||
|
|
||||||
const parsedPort = Number(port.value);
|
const parsedPort = Number(port.value);
|
||||||
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||||
@@ -416,6 +432,7 @@ async function saveSettings(event: Event): Promise<void> {
|
|||||||
busy = true;
|
busy = true;
|
||||||
settings = {
|
settings = {
|
||||||
workerDomain: worker.value.trim(),
|
workerDomain: worker.value.trim(),
|
||||||
|
cfProxyDomain: cfProxy.value.trim(),
|
||||||
lanMode: lanMode.checked,
|
lanMode: lanMode.checked,
|
||||||
port: parsedPort,
|
port: parsedPort,
|
||||||
};
|
};
|
||||||
@@ -451,7 +468,7 @@ async function copyDiagnostics(): Promise<void> {
|
|||||||
// Public reports exclude proxy links, worker domains and free-form logs.
|
// Public reports exclude proxy links, worker domains and free-form logs.
|
||||||
const report = {
|
const report = {
|
||||||
application: "TGLock",
|
application: "TGLock",
|
||||||
version: "2.0.0-beta.16",
|
version: "2.0.0-beta.17",
|
||||||
running: status.running,
|
running: status.running,
|
||||||
activeConnections: status.activeConnections,
|
activeConnections: status.activeConnections,
|
||||||
tunnels: status.tunnels,
|
tunnels: status.tunnels,
|
||||||
|
|||||||
Reference in New Issue
Block a user