mirror of
https://github.com/by-sonic/tglock.git
synced 2026-09-19 17:00:38 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb72e4acc | |||
| 60ae065938 | |||
| 1a56e1c96e | |||
| a571b4289a | |||
| fe85784550 |
@@ -64,13 +64,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: TGLock v__VERSION__
|
releaseName: TGLock v__VERSION__
|
||||||
|
# Только инструкция по скачиванию: она одна и та же для любой версии.
|
||||||
|
# Список изменений дописывается в release notes при выпуске — иначе
|
||||||
|
# выходит как в бетах 1-7, где каждая несла один и тот же текст «новый
|
||||||
|
# TGLock, полностью обновлённый интерфейс» и понять, что поменялось,
|
||||||
|
# было невозможно.
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
Новый TGLock: адаптивный MTProto/WebSocket-транспорт и полностью обновлённый интерфейс на Tauri 2.
|
## Что скачивать
|
||||||
|
|
||||||
- macOS: скачайте универсальный `.dmg` или `.app.tar.gz`
|
- **Windows** — `.exe`, установщик
|
||||||
- Windows: скачайте `.exe` установщик
|
- **macOS** — универсальный `.dmg` (или `.app.tar.gz`)
|
||||||
- Linux: скачайте `.AppImage` или `.deb`
|
- **Linux** — `.AppImage` или `.deb`
|
||||||
- Сервер или машина без монитора: скачайте `tglock-cli-*` — там нет графического интерфейса
|
- **Сервер, контейнер, машина без монитора** — `tglock-cli-*`: то же самое, но без графического интерфейса
|
||||||
|
|
||||||
|
Если Windows ругается на файл — [почему так и что с этим делать](https://github.com/by-sonic/tglock#-антивирус-ругается-smartscreen-предупреждает-virustotal-показывает-детекты).
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
|||||||
Generated
+1
-1
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.6"
|
version = "2.0.0-beta.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cipher",
|
"cipher",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tglock"
|
name = "tglock"
|
||||||
version = "2.0.0-beta.6"
|
version = "2.0.0-beta.8"
|
||||||
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"
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ Telegram → Настройки → **Продвинутые** → Тип сое
|
|||||||
|
|
||||||
В окне TGLock включи галочку **LAN** — приложение начнёт слушать на `0.0.0.0`. Все устройства в домашней сети (телефон, планшет, ноутбук, телевизор) смогут подключиться к `<IP-компьютера>:1080` и тоже получить рабочий Telegram.
|
В окне TGLock включи галочку **LAN** — приложение начнёт слушать на `0.0.0.0`. Все устройства в домашней сети (телефон, планшет, ноутбук, телевизор) смогут подключиться к `<IP-компьютера>:1080` и тоже получить рабочий Telegram.
|
||||||
|
|
||||||
Ссылку `tg://proxy` с уже подставленным адресом TGLock открывает сам при включении — её достаточно переслать себе в Telegram и открыть на телефоне. Если нужен адрес руками: `ipconfig` на Windows, `ip a` на Linux, `ifconfig` на macOS.
|
Когда LAN-режим включён, TGLock показывает в окне **готовый адрес** вида `192.168.1.7:1080` — нажми на него, чтобы скопировать. Плюс он сам открывает ссылку `tg://proxy`, где уже подставлены адрес, порт и секрет: её достаточно переслать себе в Telegram и открыть на телефоне.
|
||||||
|
|
||||||
|
> ⚠️ На другом устройстве **не вводи `127.0.0.1`** — для него это оно само, а не твой компьютер. Нужен именно тот адрес, который показан в окне TGLock. Это же касается телефона в эмуляторе (MuMuPlayer, BlueStacks и подобных): внутри эмулятора Android — отдельная машина со своей сетью.
|
||||||
|
|
||||||
|
Если адрес нужен руками: `ipconfig` на Windows, `ip a` на Linux, `ifconfig` на macOS.
|
||||||
|
|
||||||
Удобно, если дома один компьютер всегда включён — он становится «домашним Telegram-роутером».
|
Удобно, если дома один компьютер всегда включён — он становится «домашним Telegram-роутером».
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tglock-ui",
|
"name": "tglock-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0-beta.6",
|
"version": "2.0.0-beta.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 1420",
|
"dev": "vite --port 1420",
|
||||||
|
|||||||
+13
-2
@@ -142,6 +142,15 @@ async fn serve(args: Args) -> Result<(), String> {
|
|||||||
"Ссылка для Telegram: {}",
|
"Ссылка для Telegram: {}",
|
||||||
listen.telegram_link(&stats.telegram_secret())
|
listen.telegram_link(&stats.telegram_secret())
|
||||||
));
|
));
|
||||||
|
// Запись секрета могла провалиться — тогда после перезапуска ссылка
|
||||||
|
// изменится и Telegram скажет «прокси настроен неверно». Раньше это
|
||||||
|
// происходило молча (by-sonic/tglock#37).
|
||||||
|
if let Some(error) = stats.secret_write_error() {
|
||||||
|
say(&format!(
|
||||||
|
"Внимание: секрет НЕ сохранён ({error}). После перезапуска ссылка \
|
||||||
|
изменится, и Telegram откажется подключаться к старой"
|
||||||
|
));
|
||||||
|
}
|
||||||
if matches!(settings.secret, config::SecretSource::Ephemeral) {
|
if matches!(settings.secret, config::SecretSource::Ephemeral) {
|
||||||
say(
|
say(
|
||||||
"Внимание: секрет не закреплён и будет новым после перезапуска — \
|
"Внимание: секрет не закреплён и будет новым после перезапуска — \
|
||||||
@@ -202,13 +211,15 @@ async fn watch_status(stats: Arc<proxy::Stats>) {
|
|||||||
stats.last_dc.load(Ordering::Relaxed),
|
stats.last_dc.load(Ordering::Relaxed),
|
||||||
stats.last_route.load(Ordering::Relaxed),
|
stats.last_route.load(Ordering::Relaxed),
|
||||||
stats.ws_failures.load(Ordering::Relaxed),
|
stats.ws_failures.load(Ordering::Relaxed),
|
||||||
|
stats.route_failures(),
|
||||||
);
|
);
|
||||||
if previous.as_ref() == Some(¤t) {
|
if previous.as_ref() == Some(¤t) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (active, tunnels, dc, route, failures) = current;
|
let (active, tunnels, dc, route, failures, route_failures) = current;
|
||||||
let line = format!(
|
let line = format!(
|
||||||
"соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures}",
|
"соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures} · \
|
||||||
|
падений маршрутов {route_failures}",
|
||||||
if dc > 0 {
|
if dc > 0 {
|
||||||
format!("DC{dc}")
|
format!("DC{dc}")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ impl Resolved {
|
|||||||
let stats = match &self.secret {
|
let stats = match &self.secret {
|
||||||
SecretSource::Inline(secret) => proxy::Stats::with_secret(*secret),
|
SecretSource::Inline(secret) => proxy::Stats::with_secret(*secret),
|
||||||
SecretSource::File(path) => {
|
SecretSource::File(path) => {
|
||||||
proxy::Stats::with_secret(mtproto::load_or_create_secret_at(path))
|
proxy::Stats::with_stored_secret(mtproto::load_or_create_secret_at(path))
|
||||||
}
|
}
|
||||||
SecretSource::Ephemeral => proxy::Stats::new(),
|
SecretSource::Ephemeral => proxy::Stats::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
+72
-1
@@ -44,8 +44,18 @@ struct StatusSnapshot {
|
|||||||
data_center: Option<u16>,
|
data_center: Option<u16>,
|
||||||
route: String,
|
route: String,
|
||||||
failures: u32,
|
failures: u32,
|
||||||
|
/// Падения отдельных маршрутов. Растёт даже когда соединение в итоге
|
||||||
|
/// состоялось через запасной адрес (by-sonic/tglock#32).
|
||||||
|
route_failures: u32,
|
||||||
uptime_seconds: u64,
|
uptime_seconds: u64,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
/// Адрес, который нужно вписать в Telegram на другом устройстве.
|
||||||
|
///
|
||||||
|
/// В LAN-режиме это адрес этого компьютера в локальной сети. Люди искали
|
||||||
|
/// его в интерфейсе и не находили: вписывали `127.0.0.1`, который на
|
||||||
|
/// телефоне или в эмуляторе означает само устройство, и подключение не
|
||||||
|
/// работало (by-sonic/tglock#36).
|
||||||
|
share_address: Option<String>,
|
||||||
logs: Vec<LogLine>,
|
logs: Vec<LogLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +63,10 @@ struct AppState {
|
|||||||
stats: Arc<proxy::Stats>,
|
stats: Arc<proxy::Stats>,
|
||||||
settings: Mutex<Settings>,
|
settings: Mutex<Settings>,
|
||||||
active_port: Mutex<u16>,
|
active_port: Mutex<u16>,
|
||||||
|
/// Слушатель работающего прокси. Нужен, чтобы показать адрес для других
|
||||||
|
/// устройств именно тот, на котором прокси реально поднят, а не тот, что
|
||||||
|
/// сейчас выбран в настройках.
|
||||||
|
active_listen: Mutex<Option<ListenConfig>>,
|
||||||
started_at: Mutex<Option<Instant>>,
|
started_at: Mutex<Option<Instant>>,
|
||||||
logs: Arc<Mutex<Vec<LogLine>>>,
|
logs: Arc<Mutex<Vec<LogLine>>>,
|
||||||
settings_path: PathBuf,
|
settings_path: PathBuf,
|
||||||
@@ -68,6 +82,7 @@ impl AppState {
|
|||||||
stats: proxy::Stats::new(),
|
stats: proxy::Stats::new(),
|
||||||
settings: Mutex::new(settings),
|
settings: Mutex::new(settings),
|
||||||
active_port: Mutex::new(proxy::DEFAULT_PORT),
|
active_port: Mutex::new(proxy::DEFAULT_PORT),
|
||||||
|
active_listen: Mutex::new(None),
|
||||||
started_at: Mutex::new(None),
|
started_at: Mutex::new(None),
|
||||||
logs: Arc::new(Mutex::new(Vec::new())),
|
logs: Arc::new(Mutex::new(Vec::new())),
|
||||||
settings_path,
|
settings_path,
|
||||||
@@ -96,12 +111,14 @@ impl AppState {
|
|||||||
data_center: (data_center > 0).then_some(data_center),
|
data_center: (data_center > 0).then_some(data_center),
|
||||||
route: route.to_owned(),
|
route: route.to_owned(),
|
||||||
failures: self.stats.ws_failures.load(Ordering::Relaxed),
|
failures: self.stats.ws_failures.load(Ordering::Relaxed),
|
||||||
|
route_failures: self.stats.route_failures(),
|
||||||
uptime_seconds: self
|
uptime_seconds: self
|
||||||
.started_at
|
.started_at
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map_or(0, |started| started.elapsed().as_secs()),
|
.map_or(0, |started| started.elapsed().as_secs()),
|
||||||
port: *self.active_port.lock().unwrap(),
|
port: *self.active_port.lock().unwrap(),
|
||||||
|
share_address: share_address(*self.active_listen.lock().unwrap()),
|
||||||
logs: self.logs.lock().unwrap().clone(),
|
logs: self.logs.lock().unwrap().clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +135,18 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Адрес, который нужно вписать в Telegram на другом устройстве.
|
||||||
|
///
|
||||||
|
/// Только для слушателя на `0.0.0.0`: на loopback делиться нечем, туда никто
|
||||||
|
/// извне не достучится. Возвращается адрес этой машины в сети, а не `0.0.0.0`
|
||||||
|
/// и не `127.0.0.1` — последний на телефоне или в эмуляторе означает само
|
||||||
|
/// устройство, и именно на этом спотыкались (by-sonic/tglock#36).
|
||||||
|
fn share_address(listen: Option<ListenConfig>) -> Option<String> {
|
||||||
|
listen
|
||||||
|
.filter(|listen| listen.addr.ip().is_unspecified())
|
||||||
|
.map(|listen| format!("{}:{}", listen.advertised_host(), listen.addr.port()))
|
||||||
|
}
|
||||||
|
|
||||||
fn current_time() -> String {
|
fn current_time() -> String {
|
||||||
let seconds = SystemTime::now()
|
let seconds = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -173,6 +202,8 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
|
|||||||
ListenConfig::loopback(settings.port)
|
ListenConfig::loopback(settings.port)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
*state.active_listen.lock().unwrap() = Some(listen);
|
||||||
|
|
||||||
let stats = state.stats.clone();
|
let stats = state.stats.clone();
|
||||||
let logs = state.logs.clone();
|
let logs = state.logs.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -190,6 +221,7 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
|
|||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(220));
|
std::thread::sleep(std::time::Duration::from_millis(220));
|
||||||
if !state.stats.running.load(Ordering::SeqCst) {
|
if !state.stats.running.load(Ordering::SeqCst) {
|
||||||
|
*state.active_listen.lock().unwrap() = None;
|
||||||
*state.started_at.lock().unwrap() = None;
|
*state.started_at.lock().unwrap() = None;
|
||||||
return Err(state
|
return Err(state
|
||||||
.logs
|
.logs
|
||||||
@@ -209,6 +241,7 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot {
|
fn stop_proxy(state: State<'_, AppState>) -> StatusSnapshot {
|
||||||
state.stats.stop();
|
state.stats.stop();
|
||||||
|
*state.active_listen.lock().unwrap() = None;
|
||||||
*state.started_at.lock().unwrap() = None;
|
*state.started_at.lock().unwrap() = None;
|
||||||
state.log("Защита выключена", false);
|
state.log("Защита выключена", false);
|
||||||
state.snapshot()
|
state.snapshot()
|
||||||
@@ -279,7 +312,17 @@ fn main() {
|
|||||||
.app_config_dir()
|
.app_config_dir()
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
.join("settings.json");
|
.join("settings.json");
|
||||||
app.manage(AppState::new(settings_path));
|
let state = AppState::new(settings_path);
|
||||||
|
// Если секрет не удалось записать, ссылка tg://proxy изменится после
|
||||||
|
// перезапуска и Telegram откажется подключаться к сохранённой.
|
||||||
|
// Раньше это происходило молча (by-sonic/tglock#37).
|
||||||
|
if let Some(error) = state.stats.secret_write_error() {
|
||||||
|
state.log(
|
||||||
|
format!("Секрет не сохранён ({error}). После перезапуска ссылка изменится"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.manage(state);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -297,6 +340,34 @@ fn main() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_share_when_the_proxy_is_off_or_local() {
|
||||||
|
assert_eq!(
|
||||||
|
share_address(None),
|
||||||
|
None,
|
||||||
|
"выключенный прокси нечего делить"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
share_address(Some(ListenConfig::loopback(1080))),
|
||||||
|
None,
|
||||||
|
"на loopback снаружи никто не подключится"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_mode_shares_a_reachable_address() {
|
||||||
|
let shown = share_address(Some(ListenConfig::lan(1443))).expect("в LAN-режиме адрес нужен");
|
||||||
|
assert!(shown.ends_with(":1443"), "порт должен быть виден: {shown}");
|
||||||
|
assert!(
|
||||||
|
!shown.starts_with("0.0.0.0"),
|
||||||
|
"0.0.0.0 нельзя вписать в Telegram: {shown}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!shown.starts_with("127.0.0.1"),
|
||||||
|
"127.0.0.1 на другом устройстве означает само устройство: {shown}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn software_rendering_is_requested_by_default() {
|
fn software_rendering_is_requested_by_default() {
|
||||||
let vars = software_rendering_vars(false, |_| false);
|
let vars = software_rendering_vars(false, |_| false);
|
||||||
|
|||||||
+105
-21
@@ -51,31 +51,64 @@ pub fn generate_secret() -> [u8; 16] {
|
|||||||
secret
|
secret
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reuse the secret stored at `path`, creating it if it is missing or unusable.
|
/// Секрет прокси и то, лежит ли он на диске.
|
||||||
|
pub struct StoredSecret {
|
||||||
|
pub value: [u8; 16],
|
||||||
|
/// Ошибка, из-за которой секрет не удалось сохранить.
|
||||||
|
///
|
||||||
|
/// Если она есть, при следующем запуске секрет будет другим, ссылка
|
||||||
|
/// `tg://proxy` перестанет совпадать с сохранённой в Telegram, и Telegram
|
||||||
|
/// скажет «прокси настроен неверно и будет отключён». Раньше запись
|
||||||
|
/// провалившись молчала, и понять причину было невозможно
|
||||||
|
/// (by-sonic/tglock#37).
|
||||||
|
pub write_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredSecret {
|
||||||
|
/// Секрет действительно переживёт перезапуск.
|
||||||
|
pub fn is_persistent(&self) -> bool {
|
||||||
|
self.write_error.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Взять секрет из файла, создав его, если файла нет или он испорчен.
|
||||||
///
|
///
|
||||||
/// A daemon needs this: the secret is half of the `tg://proxy` link, so a
|
/// Секрет — половина ссылки `tg://proxy`, поэтому сервис, придумывающий новый
|
||||||
/// service that invents a new one on every restart silently invalidates every
|
/// при каждом старте, отключает всех уже настроенных клиентов.
|
||||||
/// client that was already configured.
|
pub fn load_or_create_secret_at(path: &Path) -> StoredSecret {
|
||||||
pub fn load_or_create_secret_at(path: &Path) -> [u8; 16] {
|
|
||||||
if let Ok(value) = std::fs::read_to_string(path) {
|
if let Ok(value) = std::fs::read_to_string(path) {
|
||||||
if let Some(secret) = parse_secret_hex(value.trim()) {
|
if let Some(value) = parse_secret_hex(value.trim()) {
|
||||||
return secret;
|
return StoredSecret {
|
||||||
|
value,
|
||||||
|
write_error: None,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret = generate_secret();
|
let value = generate_secret();
|
||||||
|
let write_error = store_secret(path, &secret_hex(&value))
|
||||||
|
.err()
|
||||||
|
.map(|error| format!("{}: {error}", path.display()));
|
||||||
|
StoredSecret { value, write_error }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_secret(path: &Path, value: &str) -> std::io::Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
write_secret_file(path, &secret_hex(&secret));
|
write_secret_file(path, value)
|
||||||
secret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
pub fn load_or_create_secret() -> [u8; 16] {
|
pub fn load_or_create_secret() -> StoredSecret {
|
||||||
match secret_path() {
|
match secret_path() {
|
||||||
Some(path) => load_or_create_secret_at(&path),
|
Some(path) => load_or_create_secret_at(&path),
|
||||||
None => generate_secret(),
|
None => StoredSecret {
|
||||||
|
value: generate_secret(),
|
||||||
|
write_error: Some("не удалось определить папку для секрета в этой системе".to_owned()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,23 +138,21 @@ fn secret_path() -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn write_secret_file(path: &Path, value: &str) {
|
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.mode(0o600)
|
.mode(0o600)
|
||||||
.open(path)
|
.open(path)?;
|
||||||
{
|
file.write_all(value.as_bytes())
|
||||||
let _ = file.write_all(value.as_bytes());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn write_secret_file(path: &Path, value: &str) {
|
fn write_secret_file(path: &Path, value: &str) -> std::io::Result<()> {
|
||||||
let _ = std::fs::write(path, value);
|
std::fs::write(path, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secret_hex(secret: &[u8; 16]) -> String {
|
pub fn secret_hex(secret: &[u8; 16]) -> String {
|
||||||
@@ -514,6 +545,59 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_write_is_reported_instead_of_swallowed() {
|
||||||
|
// Раньше ошибка записи выбрасывалась, секрет генерировался заново при
|
||||||
|
// каждом запуске, и Telegram говорил «прокси настроен неверно» без
|
||||||
|
// единой подсказки почему (by-sonic/tglock#37).
|
||||||
|
let blocker = std::env::temp_dir().join(format!(
|
||||||
|
"tglock-not-a-dir-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
std::fs::write(&blocker, "я файл, а не папка").unwrap();
|
||||||
|
|
||||||
|
// Родитель пути — обычный файл, поэтому создать каталог невозможно.
|
||||||
|
let stored = load_or_create_secret_at(&blocker.join("secret"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!stored.is_persistent(),
|
||||||
|
"неудачная запись обязана быть видна"
|
||||||
|
);
|
||||||
|
let error = stored.write_error.expect("должно быть сообщение об ошибке");
|
||||||
|
assert!(
|
||||||
|
error.contains("secret"),
|
||||||
|
"в сообщении должен быть путь, получено: {error}"
|
||||||
|
);
|
||||||
|
// Секрет всё равно выдан: прокси работает, просто до перезапуска.
|
||||||
|
assert_ne!(stored.value, [0; 16]);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&blocker);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_successful_write_reports_no_error() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"tglock-secret-ok-{}-{:?}",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let first = load_or_create_secret_at(&path);
|
||||||
|
assert!(first.is_persistent(), "запись в temp должна удаваться");
|
||||||
|
|
||||||
|
// Второй запуск читает готовый файл и тоже не жалуется.
|
||||||
|
let second = load_or_create_secret_at(&path);
|
||||||
|
assert!(second.is_persistent());
|
||||||
|
assert_eq!(
|
||||||
|
first.value, second.value,
|
||||||
|
"секрет должен переживать перезапуск"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_a_secret_copied_from_a_tg_link() {
|
fn accepts_a_secret_copied_from_a_tg_link() {
|
||||||
let expected = [
|
let expected = [
|
||||||
|
|||||||
+38
-4
@@ -27,12 +27,23 @@ pub struct Stats {
|
|||||||
pub last_route: AtomicU8,
|
pub last_route: AtomicU8,
|
||||||
transport: crate::transport::TransportEngine,
|
transport: crate::transport::TransportEngine,
|
||||||
secret: [u8; 16],
|
secret: [u8; 16],
|
||||||
|
/// Почему секрет не удалось сохранить, если не удалось.
|
||||||
|
secret_write_error: Option<String>,
|
||||||
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
|
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stats {
|
impl Stats {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Self::with_secret(initial_secret())
|
Self::with_stored_secret(initial_secret())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Построить с секретом, про который известно, сохранился он на диск или нет.
|
||||||
|
///
|
||||||
|
/// Если не сохранился, при следующем запуске ссылка `tg://proxy` изменится и
|
||||||
|
/// Telegram скажет «прокси настроен неверно и будет отключён». Раньше это
|
||||||
|
/// происходило молча (by-sonic/tglock#37).
|
||||||
|
pub fn with_stored_secret(stored: crate::mtproto::StoredSecret) -> Arc<Self> {
|
||||||
|
Self::build(stored.value, stored.write_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build with an explicit proxy secret.
|
/// Build with an explicit proxy secret.
|
||||||
@@ -40,6 +51,15 @@ impl Stats {
|
|||||||
/// A daemon must pin this: the secret is half of the `tg://proxy` link, so
|
/// A daemon must pin this: the secret is half of the `tg://proxy` link, so
|
||||||
/// generating a fresh one on restart breaks every configured client.
|
/// generating a fresh one on restart breaks every configured client.
|
||||||
pub fn with_secret(secret: [u8; 16]) -> Arc<Self> {
|
pub fn with_secret(secret: [u8; 16]) -> Arc<Self> {
|
||||||
|
Self::build(secret, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сообщение о том, почему секрет не сохранён, если он не сохранён.
|
||||||
|
pub fn secret_write_error(&self) -> Option<&str> {
|
||||||
|
self.secret_write_error.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(secret: [u8; 16], secret_write_error: Option<String>) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
running: AtomicBool::new(false),
|
running: AtomicBool::new(false),
|
||||||
active: AtomicU32::new(0),
|
active: AtomicU32::new(0),
|
||||||
@@ -50,6 +70,7 @@ impl Stats {
|
|||||||
last_route: AtomicU8::new(0),
|
last_route: AtomicU8::new(0),
|
||||||
transport: crate::transport::TransportEngine::new(),
|
transport: crate::transport::TransportEngine::new(),
|
||||||
secret,
|
secret,
|
||||||
|
secret_write_error,
|
||||||
shutdown: Mutex::new(None),
|
shutdown: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,6 +79,16 @@ impl Stats {
|
|||||||
crate::mtproto::telegram_secret(&self.secret)
|
crate::mtproto::telegram_secret(&self.secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Сколько отдельных маршрутов не ответило.
|
||||||
|
///
|
||||||
|
/// Отличается от `ws_failures`: тот растёт только когда упали все маршруты
|
||||||
|
/// и соединение не состоялось. Этот показывает перебор, который прошёл
|
||||||
|
/// незаметно — например, когда закреплённый адрес мёртв, а запасной
|
||||||
|
/// работает (by-sonic/tglock#32).
|
||||||
|
pub fn route_failures(&self) -> u32 {
|
||||||
|
self.transport.route_failures()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_worker_domain(&self, domain: &str) {
|
pub fn set_worker_domain(&self, domain: &str) {
|
||||||
let domains = domain
|
let domains = domain
|
||||||
.split([',', ';', ' '])
|
.split([',', ';', ' '])
|
||||||
@@ -75,13 +106,16 @@ impl Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
fn initial_secret() -> [u8; 16] {
|
fn initial_secret() -> crate::mtproto::StoredSecret {
|
||||||
crate::mtproto::load_or_create_secret()
|
crate::mtproto::load_or_create_secret()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn initial_secret() -> [u8; 16] {
|
fn initial_secret() -> crate::mtproto::StoredSecret {
|
||||||
crate::mtproto::generate_secret()
|
crate::mtproto::StoredSecret {
|
||||||
|
value: crate::mtproto::generate_secret(),
|
||||||
|
write_error: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim the local port.
|
/// Claim the local port.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -113,6 +114,14 @@ struct HealthState {
|
|||||||
pub struct TransportEngine {
|
pub struct TransportEngine {
|
||||||
health: Mutex<HealthState>,
|
health: Mutex<HealthState>,
|
||||||
worker_domains: Mutex<Vec<String>>,
|
worker_domains: Mutex<Vec<String>>,
|
||||||
|
/// Сколько раз отдельный маршрут не ответил.
|
||||||
|
///
|
||||||
|
/// Считается отдельно от `Stats::ws_failures`, который растёт только когда
|
||||||
|
/// упали ВСЕ маршруты. Из-за этого диагностика показывала «сбоев 0», пока
|
||||||
|
/// закреплённый адрес был недоступен и каждое холодное соединение молча
|
||||||
|
/// откатывалось на следующий маршрут, тратя на это до восьми секунд
|
||||||
|
/// (by-sonic/tglock#32).
|
||||||
|
route_failures: AtomicU32,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
forced_routes: Mutex<Vec<Route>>,
|
forced_routes: Mutex<Vec<Route>>,
|
||||||
}
|
}
|
||||||
@@ -258,7 +267,13 @@ impl TransportEngine {
|
|||||||
health.preferred.insert(key, route.clone());
|
health.preferred.insert(key, route.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Сколько отдельных маршрутов не ответило за время работы.
|
||||||
|
pub fn route_failures(&self) -> u32 {
|
||||||
|
self.route_failures.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
fn record_failure(&self, route: &Route) {
|
fn record_failure(&self, route: &Route) {
|
||||||
|
self.route_failures.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut health = self.health.lock().unwrap();
|
let mut health = self.health.lock().unwrap();
|
||||||
let failures = health
|
let failures = health
|
||||||
.routes
|
.routes
|
||||||
@@ -656,6 +671,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_route_failure_is_counted() {
|
||||||
|
// Диагностика показывала «сбоев 0», пока закреплённый адрес был мёртв и
|
||||||
|
// соединения молча откатывались на запасной. Счётчик маршрутов должен
|
||||||
|
// видеть каждое такое падение.
|
||||||
|
let engine = TransportEngine::new();
|
||||||
|
let routes = routes_for_dc(2, false);
|
||||||
|
assert_eq!(engine.route_failures(), 0);
|
||||||
|
|
||||||
|
engine.record_failure(&routes[0]);
|
||||||
|
assert_eq!(engine.route_failures(), 1);
|
||||||
|
|
||||||
|
engine.record_failure(&routes[0]);
|
||||||
|
engine.record_failure(&routes[1]);
|
||||||
|
assert_eq!(
|
||||||
|
engine.route_failures(),
|
||||||
|
3,
|
||||||
|
"считаются все падения, включая повторные по тому же маршруту"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Успех не обнуляет историю: она нужна, чтобы понять, что маршруты
|
||||||
|
// перебирались, даже когда в итоге всё соединилось.
|
||||||
|
engine.record_success(
|
||||||
|
DcKey {
|
||||||
|
dc: 2,
|
||||||
|
media: false,
|
||||||
|
},
|
||||||
|
&routes[1],
|
||||||
|
);
|
||||||
|
assert_eq!(engine.route_failures(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn documented_worker_contract_matches_the_requested_path() {
|
fn documented_worker_contract_matches_the_requested_path() {
|
||||||
// docs/CLOUDFLARE_WORKER.md promises exactly this shape.
|
// docs/CLOUDFLARE_WORKER.md promises exactly this shape.
|
||||||
|
|||||||
+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.6",
|
"version": "2.0.0-beta.8",
|
||||||
"identifier": "com.bysonic.tglock",
|
"identifier": "com.bysonic.tglock",
|
||||||
"mainBinaryName": "tglock",
|
"mainBinaryName": "tglock",
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
+40
@@ -10,8 +10,11 @@ type Status = {
|
|||||||
dataCenter: number | null;
|
dataCenter: number | null;
|
||||||
route: string;
|
route: string;
|
||||||
failures: number;
|
failures: number;
|
||||||
|
routeFailures: number;
|
||||||
uptimeSeconds: number;
|
uptimeSeconds: number;
|
||||||
port: number;
|
port: number;
|
||||||
|
/// Адрес для других устройств. Приходит только в LAN-режиме.
|
||||||
|
shareAddress: string | null;
|
||||||
logs: LogLine[];
|
logs: LogLine[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,8 +40,10 @@ let status: Status = {
|
|||||||
dataCenter: null,
|
dataCenter: null,
|
||||||
route: "Маршрут ещё не выбран",
|
route: "Маршрут ещё не выбран",
|
||||||
failures: 0,
|
failures: 0,
|
||||||
|
routeFailures: 0,
|
||||||
uptimeSeconds: 0,
|
uptimeSeconds: 0,
|
||||||
port: 1080,
|
port: 1080,
|
||||||
|
shareAddress: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
};
|
};
|
||||||
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
|
let settings: Settings = { lanMode: false, port: 1080, workerDomain: "" };
|
||||||
@@ -145,6 +150,18 @@ function renderHome(): void {
|
|||||||
<span>порт ${status.port}</span>
|
<span>порт ${status.port}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${status.shareAddress ? `
|
||||||
|
<div class="share-card">
|
||||||
|
<div class="share-label">Адрес для других устройств</div>
|
||||||
|
<button id="copy-address" class="share-address" title="Нажмите, чтобы скопировать">
|
||||||
|
${escapeHtml(status.shareAddress)}
|
||||||
|
</button>
|
||||||
|
<div class="share-hint">
|
||||||
|
Впишите его в Telegram на телефоне. Не <code>127.0.0.1</code> — на другом
|
||||||
|
устройстве это означает само устройство.
|
||||||
|
</div>
|
||||||
|
</div>` : ""}
|
||||||
|
|
||||||
<nav class="bottom-nav" aria-label="Разделы">
|
<nav class="bottom-nav" aria-label="Разделы">
|
||||||
<button id="settings-nav" class="nav-button">
|
<button id="settings-nav" class="nav-button">
|
||||||
<span class="nav-icon">${icons.settings}</span>
|
<span class="nav-icon">${icons.settings}</span>
|
||||||
@@ -160,6 +177,7 @@ function renderHome(): void {
|
|||||||
`, "home-page");
|
`, "home-page");
|
||||||
|
|
||||||
document.querySelector("#power")?.addEventListener("click", toggleProtection);
|
document.querySelector("#power")?.addEventListener("click", toggleProtection);
|
||||||
|
document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress);
|
||||||
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
|
document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings"));
|
||||||
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
|
document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics"));
|
||||||
}
|
}
|
||||||
@@ -261,8 +279,18 @@ function renderDiagnostics(): void {
|
|||||||
<span>Время работы</span>
|
<span>Время работы</span>
|
||||||
<strong class="time">${formatUptime(status.uptimeSeconds)}</strong>
|
<strong class="time">${formatUptime(status.uptimeSeconds)}</strong>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>Падений маршрутов</span>
|
||||||
|
<strong>${status.routeFailures}</strong>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="field-hint">
|
||||||
|
«Падений маршрутов» больше нуля при работающем Telegram — это норма:
|
||||||
|
значит закреплённый адрес недоступен и подключение идёт через запасной.
|
||||||
|
Число в багрепорте помогает понять, что именно перебиралось.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="log-panel">
|
<div class="log-panel">
|
||||||
<div class="log-heading">
|
<div class="log-heading">
|
||||||
<span>Последние события</span>
|
<span>Последние события</span>
|
||||||
@@ -356,6 +384,18 @@ async function saveSettings(event: Event): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyShareAddress(): Promise<void> {
|
||||||
|
const address = status.shareAddress;
|
||||||
|
if (!address) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(address);
|
||||||
|
showToast("Адрес скопирован");
|
||||||
|
} catch {
|
||||||
|
// Буфер обмена может быть недоступен — адрес и так виден на экране.
|
||||||
|
showToast("Скопируйте адрес вручную", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(message: string, error = false): void {
|
function showToast(message: string, error = false): void {
|
||||||
window.clearTimeout(toastTimer);
|
window.clearTimeout(toastTimer);
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -847,3 +847,53 @@ input:disabled {
|
|||||||
0 19px 52px rgba(9, 182, 133, 0.4);
|
0 19px 52px rgba(9, 182, 133, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Адрес для подключения других устройств. Показывается только в LAN-режиме:
|
||||||
|
люди искали его в интерфейсе, не находили и вписывали 127.0.0.1, который на
|
||||||
|
телефоне означает сам телефон (by-sonic/tglock#36). */
|
||||||
|
.share-card {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(42, 171, 238, 0.08);
|
||||||
|
border: 1px solid rgba(42, 171, 238, 0.24);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-label {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-address {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent, #2aabee);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-address:hover,
|
||||||
|
.share-address:focus-visible {
|
||||||
|
background: rgba(42, 171, 238, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hint code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user