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:
Никита Sonic
2026-10-26 18:24:38 +00:00
committed by GitHub
parent 31bb442b0c
commit 0200f646b6
14 changed files with 414 additions and 81 deletions
Generated
+1 -1
View File
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]]
name = "tglock"
version = "2.0.0-beta.16"
version = "2.0.0-beta.17"
dependencies = [
"aes",
"cipher",
+1 -1
View File
@@ -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"
+49
View File
@@ -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, как и провайдер.
+2
View File
@@ -17,6 +17,8 @@
Если Telegram работает — **ничего настраивать не надо.** Поле Worker в настройках существует для случая, когда обычные маршруты умерли.
Если есть свой домен, проще [CF-прокси](CF_PROXY.md): тот же маршрут через Cloudflare, но без скрипта и без лимитов Worker'ов.
Обрати внимание: Worker не спасает, если Telegram недоступен *с самого воркера*. Он помогает, когда домены Telegram заблокированы **у тебя**, а датацентры Cloudflare до них дотягиваются.
## Что понадобится
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -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",
+16
View File
@@ -55,6 +55,11 @@ struct Args {
#[arg(long, value_name = "DOMAIN")]
worker: Vec<String>,
/// Свой домен на Cloudflare для CF-прокси (записи kws1…kws5, kws203 на
/// адреса Telegram, docs/CF_PROXY.md). Можно повторять
#[arg(long, value_name = "DOMAIN")]
cf_proxy: Vec<String>,
/// Проксировать и не-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());
+14
View File
@@ -33,6 +33,8 @@ pub struct FileConfig {
pub lan: Option<bool>,
pub allow_direct: Option<bool>,
pub worker: Option<Vec<String>>,
/// Свои домены на Cloudflare для CF-прокси (docs/CF_PROXY.md).
pub cf_proxy: Option<Vec<String>>,
pub secret: Option<String>,
pub secret_file: Option<PathBuf>,
pub quiet: Option<bool>,
@@ -58,6 +60,7 @@ pub struct Overrides {
pub lan: bool,
pub allow_direct: bool,
pub worker: Vec<String>,
pub cf_proxy: Vec<String>,
pub secret_file: Option<PathBuf>,
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<Resolved, String> {
} 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<Resolved, String> {
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);
}
+19 -1
View File
@@ -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!(
+22 -2
View File
@@ -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) {
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();
+261 -68
View File
@@ -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<HealthState>,
worker_domains: Mutex<Vec<String>>,
cf_proxy_domains: Mutex<Vec<String>>,
/// Сколько раз отдельный маршрут не ответил.
///
/// Считается отдельно от `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 result = split_domains(domains);
*self.worker_domains.lock().unwrap() = result.accepted.clone();
result
}
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 }
/// Задать свои домены на 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<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();
// 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<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 {
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<String> {
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
/// 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<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
}
@@ -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]));
}
}
+1 -1
View File
@@ -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": {
+4
View File
@@ -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-прокси для всего интернета — включайте осознанно.
+20 -3
View File
@@ -34,6 +34,7 @@ type Settings = {
lanMode: boolean;
port: number;
workerDomain: string;
cfProxyDomain: string;
};
const root = document.querySelector<HTMLDivElement>("#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 {
<p class="field-hint">Необязательно. Используется как резервный маршрут.</p>
</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">
<label class="setting-row" for="lan-mode">
<span>
@@ -403,9 +418,10 @@ async function saveSettings(event: Event): Promise<void> {
event.preventDefault();
if (busy || status.running) return;
const worker = document.querySelector<HTMLInputElement>("#worker");
const cfProxy = document.querySelector<HTMLInputElement>("#cf-proxy");
const lanMode = document.querySelector<HTMLInputElement>("#lan-mode");
const port = document.querySelector<HTMLInputElement>("#port");
if (!worker || !lanMode || !port) return;
if (!worker || !cfProxy || !lanMode || !port) return;
const parsedPort = Number(port.value);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
@@ -416,6 +432,7 @@ async function saveSettings(event: Event): Promise<void> {
busy = true;
settings = {
workerDomain: worker.value.trim(),
cfProxyDomain: cfProxy.value.trim(),
lanMode: lanMode.checked,
port: parsedPort,
};
@@ -451,7 +468,7 @@ async function copyDiagnostics(): Promise<void> {
// Public reports exclude proxy links, worker domains and free-form logs.
const report = {
application: "TGLock",
version: "2.0.0-beta.16",
version: "2.0.0-beta.17",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,