feat(cli): headless tglock-cli, GUI behind a feature, deep test coverage (#25)

Реализует направление PR #15 поверх архитектуры 2.0. Сам PR смерджить нельзя:
он патчит src/bypass.rs, src/network.rs и src/ws_proxy.rs, которых больше нет,
и правит системный DNS — в 2.0 это не нужно, потому что адреса Telegram зашиты
в маршрутах, а SNI остаётся настоящим. Взято разделение GUI/CLI и произвольный
bind-адрес, отброшены DNS-менеджмент и проверка root: CLI не требует прав.

Closes #10, #17 — GUI не создаёт окно без 3D-ускорения, на машине без монитора
и в виртуалке. Причина в WebView под Tauri, поэтому лечится не программным
рендером, а бинарём, в котором WebView нет вовсе: при выключенной фиче gui
Tauri и фронтенд в сборку не попадают. Отдельная задача CI собирает и гоняет
CLI на голом ubuntu без Node.js и без libwebkit2gtk.

Структура:
- src/lib.rs — ядро (mtproto, proxy, transport, config), без Tauri
- src/main.rs — GUI, required-features = ["gui"]
- src/bin/cli.rs — headless-бинарь на clap
- build.rs вызывает tauri_build только при включённой фиче gui

Исправлено по пути:
- Определение протокола: SOCKS5 и MTProto различались по первому байту, но
  is_reserved_init не исключает 0x05, поэтому примерно одно соединение из 256
  уезжало в SOCKS5-ветку и умирало. Теперь неоднозначный первый байт решается
  по полному 64-байтному init и секрету.
- Ярлык маршрута в UI: код 2 подписывался как «Cloudflare Worker», хотя это
  запасной Telegram IP, а системный DNS и настоящий Worker оба показывались
  как «Автоматический маршрут». Метки переехали в transport::route_label,
  общий для обоих интерфейсов.
- Секрет прокси: под DynamicUser и ProtectHome домашней папки нет, secret_path
  возвращает None и секрет генерировался заново при каждом старте, ломая всем
  настроенным клиентам tg://-ссылку. Добавлен --secret-file.
- README обещал Rust 1.75+, тогда как Cargo.toml требует 1.88 и CI это
  проверяет. Это и есть первопричина #3.

Политика доступа: прямые не-Telegram соединения разрешены только на loopback,
на любом сетевом адресе нужен явный --allow-direct. Правило из ISSUE_AUDIT о
том, что LAN не должен становиться открытым SOCKS5, теперь выражено в типе
ListenConfig и покрыто тестами.

Тесты: 46 в библиотеке + 12 в CLI. Появился сквозной тест туннеля против
мок-релея, который реализует сторону Telegram по obfuscated2 — проверяется
не внутренняя консистентность, а что реле получает ровно тот открытый текст,
который отправил клиент, и обратно. Плюс расписание backoff, фолбэк при всех
маршрутах в cooldown, валидация Worker-доменов, отказы SOCKS5, устойчивость
секрета к перезапуску и корректная остановка по SIGTERM.

Документация: секция CLI в README с юнитом systemd и Dockerfile.

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
This commit is contained in:
Никита Sonic
2026-07-30 13:12:21 +03:00
committed by GitHub
parent e622c57a0a
commit 55653ed0bc
13 changed files with 1862 additions and 166 deletions
+12 -40
View File
@@ -1,15 +1,13 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod mtproto;
mod proxy;
mod transport;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};
use tglock::config::ListenConfig;
use tglock::{proxy, transport};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -90,11 +88,7 @@ impl AppState {
fn snapshot(&self) -> StatusSnapshot {
let data_center = self.stats.last_dc.load(Ordering::Relaxed);
let route = match self.stats.last_route.load(Ordering::Relaxed) {
1 => "Telegram WebSocket",
2 => "Cloudflare Worker",
_ => "Автоматический маршрут",
};
let route = transport::route_label(self.stats.last_route.load(Ordering::Relaxed));
StatusSnapshot {
running: self.stats.running.load(Ordering::SeqCst),
active_connections: self.stats.active.load(Ordering::Relaxed),
@@ -173,10 +167,14 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
*state.started_at.lock().unwrap() = Some(Instant::now());
state.log("Запускаю защищённый маршрут…", false);
let listen = if settings.lan_mode {
ListenConfig::lan(settings.port)
} else {
ListenConfig::loopback(settings.port)
};
let stats = state.stats.clone();
let logs = state.logs.clone();
let lan_mode = settings.lan_mode;
let port = settings.port;
std::thread::spawn(move || {
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
@@ -185,7 +183,7 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
return;
}
};
if let Err(error) = runtime.block_on(proxy::run(stats, lan_mode, port)) {
if let Err(error) = runtime.block_on(proxy::run(stats, listen)) {
push_log(&logs, format!("Ошибка подключения: {error}"), true);
}
});
@@ -202,28 +200,8 @@ fn start_proxy(state: State<'_, AppState>) -> Result<StatusSnapshot, String> {
.unwrap_or_else(|| "Не удалось запустить прокси".into()));
}
state.log(
format!(
"Прокси запущен на {}:{}",
if settings.lan_mode {
"0.0.0.0"
} else {
"127.0.0.1"
},
settings.port
),
false,
);
let host = if settings.lan_mode {
local_ip().unwrap_or_else(|| "127.0.0.1".into())
} else {
"127.0.0.1".into()
};
let _ = open::that(format!(
"tg://proxy?server={host}&port={}&secret={}",
settings.port,
state.stats.telegram_secret()
));
state.log(format!("Прокси запущен на {}", listen.addr), false);
let _ = open::that(listen.telegram_link(&state.stats.telegram_secret()));
state.log("Открываю подключение в Telegram…", false);
Ok(state.snapshot())
}
@@ -244,12 +222,6 @@ fn push_log(logs: &Arc<Mutex<Vec<LogLine>>>, message: String, error: bool) {
});
}
fn local_ip() -> Option<String> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
Some(socket.local_addr().ok()?.ip().to_string())
}
fn main() {
tauri::Builder::default()
.setup(|app| {