mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc18509b9c | |||
| 098178ead4 | |||
| 7b48f0046b | |||
| 1d96609563 | |||
| 31bb442b0c | |||
| 33322ea3a3 |
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.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cipher",
|
"cipher",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.16"
|
version = "2.0.0-beta.18"
|
||||||
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"
|
||||||
@@ -38,7 +38,7 @@ path = "src/bin/cli.rs"
|
|||||||
required-features = ["cli"]
|
required-features = ["cli"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [], optional = true }
|
tauri = { version = "2", features = ["tray-icon"], optional = true }
|
||||||
tauri-plugin-opener = { version = "2", optional = true }
|
tauri-plugin-opener = { version = "2", optional = true }
|
||||||
clap = { version = "4", features = ["derive"], optional = true }
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
|
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<!-- SUPPORT-START -->
|
||||||
|
<p align="center">
|
||||||
|
<b>💛 Поддержать проект</b> · <b>USDT</b> (Tether) в сети <b>TRC20</b>:<br/>
|
||||||
|
<code>TEHHyPSeZ7t5vfVJmjtEBkqJdpfcrm1wqg</code>
|
||||||
|
</p>
|
||||||
|
<!-- SUPPORT-END -->
|
||||||
|
|
||||||
<!-- ════════════════════════ ROSEVPN — sponsor ════════════════════════ -->
|
<!-- ════════════════════════ ROSEVPN — sponsor ════════════════════════ -->
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -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 до них дотягиваются.
|
||||||
|
|
||||||
## Что понадобится
|
## Что понадобится
|
||||||
@@ -37,7 +39,9 @@
|
|||||||
|
|
||||||
### Проверка, что воркер жив
|
### Проверка, что воркер жив
|
||||||
|
|
||||||
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426). Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
Открой в браузере `https://tglock.имя.workers.dev/apiws`. Должно вернуться `expected a websocket upgrade` (HTTP 426) и строка `TGLock Worker 2026-10-26` с датой версии скрипта. Это проверяет только публикацию скрипта. Соединение Worker → Telegram обычный GET **не проверяет**.
|
||||||
|
|
||||||
|
**Строки с версией нет — работает старый скрипт**, даже если в редакторе Cloudflare открыт новый текст. Редактор показывает черновик, а запросы обслуживает последняя *опубликованная* версия: после правки нужно нажать **Deploy**. Старый скрипт на новых compatibility date рвёт каждый туннель; в журнале Worker'а это предупреждение `This TransformStream is being used as a byte stream, but received an object of non-ArrayBuffer/ArrayBufferView type`, в журнале TGLock — `WebSocket закрыт без ответа Telegram`.
|
||||||
|
|
||||||
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"version": "2.0.0-beta.16",
|
"version": "2.0.0-beta.18",
|
||||||
"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.18",
|
||||||
"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.18",
|
||||||
"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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
-3
@@ -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());
|
||||||
@@ -424,8 +431,19 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
#[cfg(desktop)]
|
||||||
|
if let Err(error) = setup_tray(app) {
|
||||||
|
app.state::<AppState>().log(
|
||||||
|
format!("Значок в трее недоступен ({error}). Закрытие окна выключит защиту"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
.on_window_event(|_window, _event| {
|
||||||
|
#[cfg(desktop)]
|
||||||
|
hide_to_tray_on_close(_window, _event);
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_status,
|
get_status,
|
||||||
get_settings,
|
get_settings,
|
||||||
@@ -435,14 +453,118 @@ pub fn run() {
|
|||||||
get_telegram_link,
|
get_telegram_link,
|
||||||
open_telegram
|
open_telegram
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("failed to run TGLock");
|
.expect("failed to build TGLock")
|
||||||
|
.run(|_app, _event| {
|
||||||
|
// В macOS окно, спрятанное в трей, возвращает и клик по значку в Dock.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let tauri::RunEvent::Reopen { .. } = _event {
|
||||||
|
show_main_window(_app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
const TRAY_ID: &str = "main";
|
||||||
|
|
||||||
|
/// Закрытие окна при включённой защите прячет его в трей, и прокси продолжает
|
||||||
|
/// работать, как в tg-ws-proxy. Выйти можно из меню трея (by-sonic/tglock#70).
|
||||||
|
/// Если защита выключена или значка в трее нет, окно закрывается как раньше:
|
||||||
|
/// иначе приложение осталось бы работать без способа до него добраться.
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn hide_to_tray_on_close(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||||
|
let tauri::WindowEvent::CloseRequested { api, .. } = event else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let app = window.app_handle();
|
||||||
|
let running = app.state::<AppState>().stats.running.load(Ordering::SeqCst);
|
||||||
|
if running && app.tray_by_id(TRAY_ID).is_some() {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn show_main_window(app: &tauri::AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn setup_tray(app: &tauri::App) -> Result<(), String> {
|
||||||
|
// В Linux значок держится на libayatana-appindicator, и без неё tray-icon
|
||||||
|
// паникует, а не возвращает ошибку. Без трея окно просто закрывается.
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray(app)))
|
||||||
|
.map_err(|_| "нет libayatana-appindicator3".to_string())?
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||||
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|
||||||
|
let show = MenuItem::with_id(app, "show", "Открыть TGLock", true, None::<&str>)?;
|
||||||
|
let telegram = MenuItem::with_id(app, "telegram", "Открыть Telegram", true, None::<&str>)?;
|
||||||
|
let separator = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &telegram, &separator, &quit])?;
|
||||||
|
|
||||||
|
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||||
|
.tooltip("TGLock")
|
||||||
|
.menu(&menu)
|
||||||
|
// В Windows и Linux левый клик открывает окно, меню — по правому.
|
||||||
|
// В macOS меню по клику привычнее.
|
||||||
|
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||||
|
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||||
|
"show" => show_main_window(app),
|
||||||
|
"telegram" => {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
if let Err(error) = open_telegram_link(app, &state) {
|
||||||
|
state.log(error, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main_window(tray.app_handle());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(icon) = app.default_window_icon() {
|
||||||
|
tray = tray.icon(icon.clone());
|
||||||
|
}
|
||||||
|
tray.build(app)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
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.18",
|
||||||
"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-прокси для всего интернета — включайте осознанно.
|
||||||
|
|||||||
+89
-22
@@ -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 = "";
|
||||||
@@ -112,6 +113,47 @@ function connectionState(): { title: string; subtitle: string; className: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML,
|
||||||
|
// кнопки становятся новыми узлами, и тап, начатый на старой кнопке и
|
||||||
|
// законченный на новой, Chromium не превращает в click. Поэтому разметка
|
||||||
|
// накладывается на уже существующие узлы: меняются только текст и атрибуты,
|
||||||
|
// а элемент заменяется лишь там, где на его месте теперь другой тег или id.
|
||||||
|
function patchRoot(html: string): void {
|
||||||
|
const next = document.createElement("template");
|
||||||
|
next.innerHTML = html;
|
||||||
|
patchChildren(root, next.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchChildren(current: Node, next: Node): void {
|
||||||
|
const nextChildren = Array.from(next.childNodes);
|
||||||
|
nextChildren.forEach((nextChild, index) => {
|
||||||
|
const currentChild = current.childNodes[index];
|
||||||
|
if (!currentChild) current.appendChild(nextChild);
|
||||||
|
else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild);
|
||||||
|
else patchNode(currentChild, nextChild);
|
||||||
|
});
|
||||||
|
while (current.childNodes.length > nextChildren.length) current.lastChild?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSlot(current: Node, next: Node): boolean {
|
||||||
|
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false;
|
||||||
|
return !(current instanceof Element && next instanceof Element) || current.id === next.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchNode(current: Node, next: Node): void {
|
||||||
|
if (!(current instanceof Element && next instanceof Element)) {
|
||||||
|
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { name } of Array.from(current.attributes)) {
|
||||||
|
if (!next.hasAttribute(name)) current.removeAttribute(name);
|
||||||
|
}
|
||||||
|
for (const { name, value } of Array.from(next.attributes)) {
|
||||||
|
if (current.getAttribute(name) !== value) current.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
patchChildren(current, next);
|
||||||
|
}
|
||||||
|
|
||||||
function shell(content: string, pageClass = ""): string {
|
function shell(content: string, pageClass = ""): string {
|
||||||
return `
|
return `
|
||||||
<main class="app-shell ${pageClass}">
|
<main class="app-shell ${pageClass}">
|
||||||
@@ -124,7 +166,7 @@ function shell(content: string, pageClass = ""): string {
|
|||||||
|
|
||||||
function renderHome(): void {
|
function renderHome(): void {
|
||||||
const connection = connectionState();
|
const connection = connectionState();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="home">
|
<section class="home">
|
||||||
<header class="brand">
|
<header class="brand">
|
||||||
<div class="wordmark"><span>TG</span>Lock</div>
|
<div class="wordmark"><span>TG</span>Lock</div>
|
||||||
@@ -190,14 +232,7 @@ function renderHome(): void {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
`, "home-page");
|
`, "home-page"));
|
||||||
|
|
||||||
document.querySelector("#power")?.addEventListener("click", toggleProtection);
|
|
||||||
document.querySelector("#open-telegram")?.addEventListener("click", openTelegram);
|
|
||||||
document.querySelector("#copy-link")?.addEventListener("click", copyTelegramLink);
|
|
||||||
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
|
|
||||||
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
|
|
||||||
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHeader(title: string, subtitle: string): string {
|
function pageHeader(title: string, subtitle: string): string {
|
||||||
@@ -213,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings(): void {
|
function renderSettings(): void {
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
||||||
|
|
||||||
@@ -232,6 +267,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>
|
||||||
@@ -260,15 +309,12 @@ function renderSettings(): void {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDiagnostics(): void {
|
function renderDiagnostics(): void {
|
||||||
const recentLogs = status.logs.slice(-4).reverse();
|
const recentLogs = status.logs.slice(-4).reverse();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
||||||
|
|
||||||
@@ -357,12 +403,31 @@ function renderDiagnostics(): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки,
|
||||||
|
// но новые (например, «Открыть Telegram» после включения) создаёт сам.
|
||||||
|
const clickActions: Record<string, () => void> = {
|
||||||
|
power: toggleProtection,
|
||||||
|
"open-telegram": openTelegram,
|
||||||
|
"copy-link": copyTelegramLink,
|
||||||
|
"copy-address": copyShareAddress,
|
||||||
|
"settings-nav": () => navigate("settings"),
|
||||||
|
"diagnostics-nav": () => navigate("diagnostics"),
|
||||||
|
"copy-diagnostics": copyDiagnostics,
|
||||||
|
back: () => navigate("home"),
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("click", (event) => {
|
||||||
|
const button = (event.target as Element).closest<HTMLButtonElement>("button[id]");
|
||||||
|
if (button && !button.disabled) clickActions[button.id]?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener("submit", (event) => {
|
||||||
|
if ((event.target as Element).id === "settings-form") void saveSettings(event);
|
||||||
|
});
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
if (view === "settings") renderSettings();
|
if (view === "settings") renderSettings();
|
||||||
else if (view === "diagnostics") renderDiagnostics();
|
else if (view === "diagnostics") renderDiagnostics();
|
||||||
@@ -403,9 +468,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 +482,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 +518,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.18",
|
||||||
running: status.running,
|
running: status.running,
|
||||||
activeConnections: status.activeConnections,
|
activeConnections: status.activeConnections,
|
||||||
tunnels: status.tunnels,
|
tunnels: status.tunnels,
|
||||||
|
|||||||
+10
-1
@@ -34,6 +34,12 @@ const CONNECT_TIMEOUT_MS = 3000;
|
|||||||
const MAX_PENDING_BYTES = 1024 * 1024;
|
const MAX_PENDING_BYTES = 1024 * 1024;
|
||||||
const MAX_PENDING_MESSAGES = 256;
|
const MAX_PENDING_MESSAGES = 256;
|
||||||
|
|
||||||
|
// Видна в ответе на проверку из браузера (/apiws без WebSocket). Редактор
|
||||||
|
// Cloudflare показывает сохранённый черновик, а работает опубликованная
|
||||||
|
// версия: без этой строки старый скрипт за новым текстом в редакторе было не
|
||||||
|
// отличить (by-sonic/tglock#53). Меняется при каждом изменении скрипта.
|
||||||
|
const WORKER_VERSION = "2026-10-26";
|
||||||
|
|
||||||
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
|
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
|
||||||
//
|
//
|
||||||
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
|
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
|
||||||
@@ -68,7 +74,10 @@ export default {
|
|||||||
return new Response("not found", { status: 404 });
|
return new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
||||||
return new Response("expected a websocket upgrade", { status: 426 });
|
return new Response(`expected a websocket upgrade\nTGLock Worker ${WORKER_VERSION}\n`, {
|
||||||
|
status: 426,
|
||||||
|
headers: { "X-TGLock-Worker": WORKER_VERSION },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
|
// Необязательный общий секрет: задай переменную TGLOCK_TOKEN в настройках
|
||||||
// воркера, и посторонние подключиться не смогут.
|
// воркера, и посторонние подключиться не смогут.
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
|
|||||||
|
|
||||||
test("HTTP deployment check does not claim Telegram connectivity", async () => {
|
test("HTTP deployment check does not claim Telegram connectivity", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
assert.equal((await f.fetch("/apiws", { Upgrade: "" })).status, 426);
|
const check = await f.fetch("/apiws", { Upgrade: "" });
|
||||||
|
assert.equal(check.status, 426);
|
||||||
|
// The browser check names the published version (#53).
|
||||||
|
assert.match(check.body, /^expected a websocket upgrade\nTGLock Worker \d{4}-\d{2}-\d{2}\n$/);
|
||||||
|
assert.match(check.headers["X-TGLock-Worker"], /^\d{4}-\d{2}-\d{2}$/);
|
||||||
assert.equal(f.state.connects, 0);
|
assert.equal(f.state.connects, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user