Files
tglock/src/config.rs
T
Никита Sonic 55653ed0bc 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>
2026-07-30 13:12:21 +03:00

136 lines
4.3 KiB
Rust

//! Listener configuration shared by the GUI and the CLI.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Where the local proxy listens and whether it is allowed to relay anything
/// other than Telegram.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ListenConfig {
pub addr: SocketAddr,
/// Relay non-Telegram destinations as a plain SOCKS5 proxy.
///
/// Loopback listeners get this for free because only local processes can
/// reach them. A listener the network can reach must opt in explicitly, so
/// that sharing TGLock across a flat never silently turns the machine into
/// an open SOCKS5 relay.
pub allow_direct: bool,
}
impl ListenConfig {
/// Listener with the default policy for the given address.
pub fn new(ip: IpAddr, port: u16) -> Self {
Self {
addr: SocketAddr::new(ip, port),
allow_direct: ip.is_loopback(),
}
}
/// `127.0.0.1` — only this machine, non-Telegram traffic relayed.
pub fn loopback(port: u16) -> Self {
Self::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
}
/// `0.0.0.0` — reachable from the local network, Telegram destinations only.
pub fn lan(port: u16) -> Self {
Self::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port)
}
/// Override the direct-relay policy. Used by `--allow-direct`.
pub fn with_allow_direct(mut self, allow_direct: bool) -> Self {
self.allow_direct = allow_direct;
self
}
/// Host to advertise in a `tg://proxy` link for this listener.
///
/// A wildcard bind is not a usable destination, so it is resolved to the
/// address this machine uses to reach the network.
pub fn advertised_host(&self) -> String {
let ip = self.addr.ip();
if ip.is_unspecified() {
outbound_ip().unwrap_or_else(|| Ipv4Addr::LOCALHOST.to_string())
} else {
ip.to_string()
}
}
/// `tg://proxy` link that points Telegram at this listener.
pub fn telegram_link(&self, secret: &str) -> String {
format!(
"tg://proxy?server={}&port={}&secret={}",
self.advertised_host(),
self.addr.port(),
secret
)
}
}
/// Local address of the interface that reaches the default route.
///
/// No packet is sent: connecting a UDP socket only makes the OS pick a route.
fn outbound_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())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_relays_direct_traffic() {
let config = ListenConfig::loopback(1080);
assert_eq!(config.addr.to_string(), "127.0.0.1:1080");
assert!(config.allow_direct);
}
#[test]
fn lan_restricts_to_telegram_by_default() {
let config = ListenConfig::lan(1080);
assert_eq!(config.addr.to_string(), "0.0.0.0:1080");
assert!(!config.allow_direct);
}
#[test]
fn any_routable_address_restricts_to_telegram() {
for ip in ["192.168.1.10", "10.0.0.5", "::"] {
let config = ListenConfig::new(ip.parse().unwrap(), 1080);
assert!(
!config.allow_direct,
"{ip} must not relay non-Telegram traffic without an explicit opt-in"
);
}
}
#[test]
fn ipv6_loopback_is_treated_as_local() {
let config = ListenConfig::new("::1".parse().unwrap(), 1080);
assert!(config.allow_direct);
assert_eq!(config.addr.to_string(), "[::1]:1080");
}
#[test]
fn allow_direct_override_is_explicit_in_both_directions() {
assert!(ListenConfig::lan(1080).with_allow_direct(true).allow_direct);
assert!(
!ListenConfig::loopback(1080)
.with_allow_direct(false)
.allow_direct
);
}
#[test]
fn link_uses_concrete_host_and_port() {
let link = ListenConfig::new("192.168.1.10".parse().unwrap(), 1443).telegram_link("ddaa");
assert_eq!(link, "tg://proxy?server=192.168.1.10&port=1443&secret=ddaa");
}
#[test]
fn wildcard_bind_never_advertises_itself() {
let host = ListenConfig::lan(1080).advertised_host();
assert_ne!(host, "0.0.0.0");
assert!(!host.is_empty());
}
}