mirror of
https://github.com/by-sonic/tglock.git
synced 2026-05-22 15:31:42 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1ecbc071 | ||
|
|
6795c6177a | ||
|
|
70c61e1330 |
175
HABR.md
Normal file
175
HABR.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Написал обход блокировки Telegram на Rust за 300 строк — без VPN, серверов и абонентки
|
||||||
|
|
||||||
|
**Простой · 6 мин · Rust · Open source · Сетевые технологии · Из песочницы**
|
||||||
|
|
||||||
|
**TL;DR:** Open-source приложение **TGLock** на Rust. Один клик — Telegram работает. Локальный SOCKS5-прокси заворачивает MTProto в WebSocket через `web.telegram.org`. Провайдер видит HTTPS. Windows, macOS, Linux. 300 строк кода. GitHub — [by-sonic/tglock](https://github.com/by-sonic/tglock).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Почему GoodbyeDPI больше не хватает
|
||||||
|
|
||||||
|
GoodbyeDPI, Zapret — отличные инструменты. Они фрагментируют пакеты, ломают сигнатуры DPI, и это работало. До определённого момента.
|
||||||
|
|
||||||
|
Проблема: провайдеры перешли от DPI к **IP-шейпингу**. Весь трафик к подсетям Telegram (149.154.x.x, 91.108.x.x) режется по скорости. Неважно, видит DPI MTProto или нет — если destination IP принадлежит Telegram, соединение троттлится.
|
||||||
|
|
||||||
|
Результат: GoodbyeDPI запущен, пакеты фрагментированы, DPI обманут — а Telegram всё равно грузится 10 секунд, медиа не приходят, звонки рвутся. Пинг 200+, постоянные переподключения.
|
||||||
|
|
||||||
|
VPN решает, но:
|
||||||
|
- Стоит денег
|
||||||
|
- Гонит **весь** трафик через чужой сервер
|
||||||
|
- Для одного Telegram — оверкилл
|
||||||
|
|
||||||
|
Нужен другой подход.
|
||||||
|
|
||||||
|
## Идея: WebSocket через web.telegram.org
|
||||||
|
|
||||||
|
Замерил: прямое TCP-соединение к серверам Telegram (149.154.167.51:443) — таймаут или 200+ мс. А вот `web.telegram.org` отвечает стабильно за 50–80 мс. Логично: это «обычный сайт», провайдер его не трогает.
|
||||||
|
|
||||||
|
Полез в [документацию MTProto](https://core.telegram.org/mtproto/transports):
|
||||||
|
|
||||||
|
> **WebSocket:** Implementation of the WebSocket transport is **pretty much the same as with TCP**... all data received and sent through WebSocket messages is to be treated as a **single duplex stream of bytes**, just like with TCP.
|
||||||
|
|
||||||
|
Telegram официально поддерживает WebSocket-транспорт. Эндпоинты `kws1-5.web.telegram.org` — полноценные точки входа в сеть Telegram через WSS.
|
||||||
|
|
||||||
|
**Схема:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram Desktop → SOCKS5 → TGLock → WSS (kws{dc}.web.telegram.org) → DC
|
||||||
|
↑
|
||||||
|
Провайдер видит: HTTPS к web.telegram.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Нет MTProto в трафике. Нет подозрительных IP. Обычный HTTPS.
|
||||||
|
|
||||||
|
## Реализация: 300 строк на Rust
|
||||||
|
|
||||||
|
Весь проект — два файла: `proxy.rs` (туннель) и `main.rs` (UI).
|
||||||
|
|
||||||
|
### SOCKS5 → определение DC → WebSocket
|
||||||
|
|
||||||
|
Когда Telegram Desktop подключается через SOCKS5, мы:
|
||||||
|
|
||||||
|
**1.** Обрабатываем SOCKS5-хендшейк и получаем destination IP.
|
||||||
|
|
||||||
|
**2.** Читаем первые 64 байта — это obfuscated2 init-пакет MTProto. Из него извлекаем настоящий DC через AES-256-CTR:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn dc_from_init(init: &[u8; 64]) -> Option<u8> {
|
||||||
|
use aes::Aes256;
|
||||||
|
use cipher::{KeyIvInit, StreamCipher};
|
||||||
|
|
||||||
|
let mut dec = *init;
|
||||||
|
let mut c = ctr::Ctr128BE::<Aes256>::new(
|
||||||
|
init[8..40].into(),
|
||||||
|
init[40..56].into(),
|
||||||
|
);
|
||||||
|
c.apply_keystream(&mut dec);
|
||||||
|
|
||||||
|
let id = i32::from_le_bytes([dec[60], dec[61], dec[62], dec[63]]);
|
||||||
|
let dc = id.unsigned_abs() as u8;
|
||||||
|
(1..=5).contains(&dc).then_some(dc)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3.** Открываем WebSocket к нужному DC с обязательным заголовком `Sec-WebSocket-Protocol: binary` и таймаутом 10 секунд:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (mut ws, _) = tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
tokio_tungstenite::connect_async_tls_with_config(req, None, false, Some(tls)),
|
||||||
|
).await??;
|
||||||
|
```
|
||||||
|
|
||||||
|
**4.** Отправляем буферизованные 64 байта init-пакета как первый WebSocket-фрейм. Дальше — двунаправленный relay в одном `tokio::select!` цикле:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
msg = ws.next() => match msg {
|
||||||
|
Some(Ok(Message::Binary(data))) => {
|
||||||
|
tcp_w.write_all(data.as_ref()).await?;
|
||||||
|
tcp_w.flush().await?;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Ping(p))) => {
|
||||||
|
ws.send(Message::Pong(p)).await?;
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
},
|
||||||
|
n = tcp_r.read(&mut buf) => match n {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => { ws.send(Message::Binary(buf[..n].to_vec())).await?; }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевой момент — **Ping/Pong**. Без ответа на Ping сервер закрывает соединение через ~2 минуты. Первая версия это игнорировала — пользователи жаловались на обрывы.
|
||||||
|
|
||||||
|
### Не-Telegram трафик
|
||||||
|
|
||||||
|
Если destination IP не принадлежит Telegram — прямой TCP passthrough. Прокси не трогает ничего лишнего.
|
||||||
|
|
||||||
|
## Стабильность: что ломалось и как починили
|
||||||
|
|
||||||
|
**Проблема 1: Обрыв через 2 минуты.**
|
||||||
|
WebSocket-сервер отправляет Ping-фреймы. Первая реализация использовала `split()` и два отдельных потока — Ping приходил в `read`-поток, а Pong нужно было отправить через `write`-поток. Решение: единый `tokio::select!` цикл без split. `biased` приоритизирует WS-чтение — Pong улетает мгновенно.
|
||||||
|
|
||||||
|
**Проблема 2: Неправильный DC.**
|
||||||
|
IP-маппинг ненадёжен: в подсети 149.154.164-167 живут и DC2, и DC4. Если отправить данные не в тот DC — сервер дропает соединение. Решение: извлекать DC из obfuscated2 init через AES-256-CTR.
|
||||||
|
|
||||||
|
**Проблема 3: Зависание на подключении.**
|
||||||
|
Если `kws*.web.telegram.org` не отвечает — прокси висел бесконечно. Решение: `tokio::time::timeout(10s)` на WebSocket connect.
|
||||||
|
|
||||||
|
**Проблема 4: Потеря данных.**
|
||||||
|
TCP-write без `flush()` мог буферизовать данные. Telegram Desktop ожидал ответ, не получал его, переподключался. Решение: явный `flush()` после каждого write.
|
||||||
|
|
||||||
|
## UI: egui, не Electron
|
||||||
|
|
||||||
|
Нативный GUI через egui. Тёмная тема, минимальный интерфейс. Бинарник ~6 МБ, без зависимостей.
|
||||||
|
|
||||||
|
Одна кнопка — ПОДКЛЮЧИТЬ/ОТКЛЮЧИТЬ. Статистика в реальном времени: активные соединения, WebSocket-туннели, текущий DC, аптайм.
|
||||||
|
|
||||||
|
## Кроссплатформенность
|
||||||
|
|
||||||
|
Ни одной строки платформо-специфичного кода. Работает на:
|
||||||
|
- **Windows** x64
|
||||||
|
- **macOS** Intel + Apple Silicon
|
||||||
|
- **Linux** x64
|
||||||
|
|
||||||
|
CI/CD через GitHub Actions — при создании тега автоматически собираются бинарники для всех платформ.
|
||||||
|
|
||||||
|
## Сравнение
|
||||||
|
|
||||||
|
| | GoodbyeDPI | Zapret | VPN | **TGLock** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Метод | Фрагментация | Desync | Туннель | WebSocket |
|
||||||
|
| Обходит IP-шейпинг | Нет | Нет | Да | **Да** |
|
||||||
|
| Нужен сервер | Нет | Нет | Да | **Нет** |
|
||||||
|
| Весь трафик | Нет | Нет | Да | **Только Telegram** |
|
||||||
|
| Кроссплатформа | Windows | Win/Mac/Linux | Да | **Win/Mac/Linux** |
|
||||||
|
| Размер | ~1 МБ | ~2 МБ | Зависит | **~6 МБ** |
|
||||||
|
|
||||||
|
## Цифры
|
||||||
|
|
||||||
|
- **300** строк кода (proxy + UI)
|
||||||
|
- **2** файла (`proxy.rs` + `main.rs`)
|
||||||
|
- **3** платформы (Windows, macOS, Linux)
|
||||||
|
- **0** серверов
|
||||||
|
- **0₽**
|
||||||
|
|
||||||
|
## Скачать
|
||||||
|
|
||||||
|
**[github.com/by-sonic/tglock](https://github.com/by-sonic/tglock)** → Releases
|
||||||
|
|
||||||
|
Или собрать: `git clone ... && cargo build --release`
|
||||||
|
|
||||||
|
**P.S.** Для полного обхода блокировок (YouTube, Discord, Instagram) — **[by sonic VPN](https://t.me/bysonicvpn_bot)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*by sonic*
|
||||||
|
|
||||||
|
**Теги:** telegram, rust, websocket, socks5, mtproto, dpi, обход блокировок, open-source
|
||||||
|
|
||||||
|
**Хабы:** Rust · Open source · Сетевые технологии
|
||||||
68
src/main.rs
68
src/main.rs
@@ -93,6 +93,9 @@ struct App {
|
|||||||
stats: Arc<proxy::Stats>,
|
stats: Arc<proxy::Stats>,
|
||||||
log: Arc<Mutex<Vec<LogLine>>>,
|
log: Arc<Mutex<Vec<LogLine>>>,
|
||||||
started_at: Option<Instant>,
|
started_at: Option<Instant>,
|
||||||
|
lan_mode: bool,
|
||||||
|
port_str: String,
|
||||||
|
active_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -101,6 +104,9 @@ impl App {
|
|||||||
stats: proxy::Stats::new(),
|
stats: proxy::Stats::new(),
|
||||||
log: Arc::new(Mutex::new(Vec::new())),
|
log: Arc::new(Mutex::new(Vec::new())),
|
||||||
started_at: None,
|
started_at: None,
|
||||||
|
lan_mode: false,
|
||||||
|
port_str: proxy::DEFAULT_PORT.to_string(),
|
||||||
|
active_port: proxy::DEFAULT_PORT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,15 +116,26 @@ impl App {
|
|||||||
|
|
||||||
fn start(&mut self) {
|
fn start(&mut self) {
|
||||||
if self.running() { return; }
|
if self.running() { return; }
|
||||||
|
|
||||||
|
let port: u16 = match self.port_str.trim().parse() {
|
||||||
|
Ok(p) if p > 0 => p,
|
||||||
|
_ => {
|
||||||
|
log(&self.log, "Неверный порт", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.active_port = port;
|
||||||
self.started_at = Some(Instant::now());
|
self.started_at = Some(Instant::now());
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
let lg = self.log.clone();
|
let lg = self.log.clone();
|
||||||
|
let lan = self.lan_mode;
|
||||||
|
|
||||||
log(&lg, "Запускаю прокси...", false);
|
log(&lg, "Запускаю прокси...", false);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
let r = rt.block_on(proxy::run(stats));
|
let r = rt.block_on(proxy::run(stats, lan, port));
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
log(&lg, &format!("Ошибка: {}", e), true);
|
log(&lg, &format!("Ошибка: {}", e), true);
|
||||||
}
|
}
|
||||||
@@ -126,7 +143,11 @@ impl App {
|
|||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
if self.running() {
|
if self.running() {
|
||||||
log(&self.log, &format!("SOCKS5 на 127.0.0.1:{}", proxy::PORT), false);
|
let addr = if lan { "0.0.0.0" } else { "127.0.0.1" };
|
||||||
|
log(&self.log, &format!("SOCKS5 на {}:{}", addr, port), false);
|
||||||
|
if lan {
|
||||||
|
log(&self.log, "LAN-режим: другие устройства могут подключаться", false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +271,27 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
ui.add_space(20.0);
|
ui.add_space(20.0);
|
||||||
|
|
||||||
|
// Options (only when stopped)
|
||||||
|
if !on {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let center = ui.available_width() / 2.0 - 130.0;
|
||||||
|
ui.add_space(center);
|
||||||
|
ui.colored_label(TEXT2, egui::RichText::new("Порт:").size(12.0));
|
||||||
|
let port_edit = egui::TextEdit::singleline(&mut self.port_str)
|
||||||
|
.desired_width(55.0)
|
||||||
|
.font(egui::TextStyle::Monospace);
|
||||||
|
ui.add(port_edit);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.checkbox(&mut self.lan_mode, "");
|
||||||
|
ui.colored_label(TEXT2, egui::RichText::new("LAN").size(12.0));
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(80, 85, 95),
|
||||||
|
egui::RichText::new("(0.0.0.0)").size(10.5),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Big button
|
// Big button
|
||||||
if !on {
|
if !on {
|
||||||
let btn = ui.add_sized(
|
let btn = ui.add_sized(
|
||||||
@@ -286,11 +328,21 @@ impl eframe::App for App {
|
|||||||
ui.colored_label(TEXT, egui::RichText::new("Настройка Telegram").size(14.0).strong());
|
ui.colored_label(TEXT, egui::RichText::new("Настройка Telegram").size(14.0).strong());
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
let server_addr = if self.lan_mode && on {
|
||||||
|
local_ip().unwrap_or_else(|| "127.0.0.1".into())
|
||||||
|
} else {
|
||||||
|
"127.0.0.1".into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_port = if on { self.active_port } else {
|
||||||
|
self.port_str.trim().parse().unwrap_or(proxy::DEFAULT_PORT)
|
||||||
|
};
|
||||||
|
|
||||||
if on {
|
if on {
|
||||||
if ui.add(egui::Button::new(
|
if ui.add(egui::Button::new(
|
||||||
egui::RichText::new("Настроить автоматически").size(13.0).color(ACCENT)
|
egui::RichText::new("Настроить автоматически").size(13.0).color(ACCENT)
|
||||||
).frame(false)).clicked() {
|
).frame(false)).clicked() {
|
||||||
let _ = open::that(format!("tg://socks?server=127.0.0.1&port={}", proxy::PORT));
|
let _ = open::that(format!("tg://socks?server={}&port={}", server_addr, display_port));
|
||||||
log(&self.log, "Открываю настройку Telegram...", false);
|
log(&self.log, "Открываю настройку Telegram...", false);
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
@@ -301,10 +353,10 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
egui::Grid::new("cfg").num_columns(2).spacing([12.0, 3.0]).show(ui, |ui| {
|
egui::Grid::new("cfg").num_columns(2).spacing([12.0, 3.0]).show(ui, |ui| {
|
||||||
ui.colored_label(TEXT2, "Сервер");
|
ui.colored_label(TEXT2, "Сервер");
|
||||||
ui.monospace("127.0.0.1");
|
ui.monospace(&server_addr);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.colored_label(TEXT2, "Порт");
|
ui.colored_label(TEXT2, "Порт");
|
||||||
ui.monospace(format!("{}", proxy::PORT));
|
ui.monospace(format!("{}", display_port));
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -329,3 +381,9 @@ fn stat(ui: &mut egui::Ui, label: &str, value: &str) {
|
|||||||
ui.colored_label(TEXT, egui::RichText::new(value).size(13.0).strong().monospace());
|
ui.colored_label(TEXT, egui::RichText::new(value).size(13.0).strong().monospace());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
use tokio_tungstenite::tungstenite;
|
use tokio_tungstenite::tungstenite;
|
||||||
use tungstenite::client::IntoClientRequest;
|
use tungstenite::client::IntoClientRequest;
|
||||||
|
|
||||||
pub const PORT: u16 = 1080;
|
pub const DEFAULT_PORT: u16 = 1080;
|
||||||
|
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
pub running: AtomicBool,
|
pub running: AtomicBool,
|
||||||
@@ -29,11 +29,12 @@ impl Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(stats: Arc<Stats>) -> Result<(), String> {
|
pub async fn run(stats: Arc<Stats>, lan: bool, port: u16) -> Result<(), String> {
|
||||||
let addr = format!("127.0.0.1:{}", PORT);
|
let host = if lan { "0.0.0.0" } else { "127.0.0.1" };
|
||||||
|
let addr = format!("{}:{}", host, port);
|
||||||
let listener = TcpListener::bind(&addr)
|
let listener = TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Port {} busy: {}", PORT, e))?;
|
.map_err(|e| format!("Port {} busy: {}", port, e))?;
|
||||||
|
|
||||||
stats.running.store(true, Ordering::SeqCst);
|
stats.running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user