5 Commits
v1.0.0 ... main

Author SHA1 Message Date
by-sonic
ccf3e3150c Rebrand VPN bot mentions to @rosevpnru_bot + RoseVPN; add promo banner at top of README 2026-05-10 15:24:24 +03:00
by-sonic
cfa50d66f3 docs: new Habr article v2 - macOS focus, LAN mode, stability fixes
Made-with: Cursor
2026-04-08 15:05:10 +03:00
by-sonic
1c1ecbc071 feat: configurable port - default 1080, editable in UI before connecting
Made-with: Cursor
2026-04-08 15:02:11 +03:00
by-sonic
6795c6177a feat: add LAN mode - bind to 0.0.0.0 for sharing proxy across local network
Made-with: Cursor
2026-04-08 15:00:22 +03:00
by-sonic
70c61e1330 Add Habr article
Made-with: Cursor:
2026-04-08 14:58:02 +03:00
4 changed files with 375 additions and 13 deletions

289
HABR.md Normal file
View File

@@ -0,0 +1,289 @@
# TGLock v2: переписал обход Telegram с нуля — теперь работает на маке, и один прокси на всю квартиру
**Простой · 7 мин · Rust · Open source · macOS · Сетевые технологии**
**TL;DR:** Полмесяца назад я выложил TGLock — обход блокировки Telegram через WebSocket-туннель. Статья залетела на 183K просмотров. А потом всё сломалось. Соединения рвались через 2 минуты, DC определялся неправильно, маководы плакали в комментах. Переписал с нуля. 350 строк. Работает на macOS, Windows, Linux. Один прокси — все устройства в квартире. Код: [github.com/by-sonic/tglock](https://github.com/by-sonic/tglock).
---
## Что случилось после первой статьи
Первая версия TGLock делала простую вещь: SOCKS5-прокси заворачивал MTProto в WebSocket через `web.telegram.org`. Провайдер видит HTTPS — Telegram работает. Концепция правильная. Реализация — нет.
Через неделю после релиза прилетело:
> «Работает 2 минуты, потом Telegram пишет ошибку прокси»
> «По умолчанию выбирает неправильное сетевое подключение, вешается на VMware-адаптер»
> «Порт 1080 занят, как поменять?»
> «А на маке будет?»
Последний вопрос задавали чаще всего. GoodbyeDPI — только Windows. Zapret — есть tpws, но это терминал и ручная настройка. GUI для обхода Telegram на маке — **не существует**. Вообще.
Решил: не патчить старый код. Переписать с нуля.
## Что было не так с v1
### Обрыв через 2 минуты
Главный баг. WebSocket-серверы Telegram (`kws*.web.telegram.org`) шлют **Ping-фреймы** каждые ~60 секунд. Если клиент не отвечает Pong — сервер закрывает соединение.
В v1 я использовал `split()` из `futures` чтобы разделить WebSocket-стрим на два потока — чтение и запись. Красиво, идиоматично, по учебнику. И сломано.
Ping приходит в read-поток. Pong нужно отправить через write-поток. Между ними — channel или shared state. В теории работает. На практике — Pong опаздывает на десятки миллисекунд, сервер считает клиента мёртвым.
**Решение в v2:** один `tokio::select!` цикл, без split. WebSocket остаётся единым объектом. `biased` приоритизирует чтение WS — Pong улетает мгновенно:
```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?; }
},
}
}
```
Обратите внимание на `flush()` после каждого write в TCP. Без него tokio буферизует данные, Telegram Desktop ждёт ответ, не дожидается, переподключается. Ещё один баг v1, который маскировался под «нестабильное соединение».
### Неправильный DC
В v1 DC определялся по IP-адресу. Таблица маппинга из документации Telegram:
```
149.154.160-163 → DC1
149.154.164-167 → DC2
91.108.56-59 → DC5
...
```
Проблема: в подсети `149.154.164-167` живут **и DC2, и DC4**. Telegram Desktop мог коннектиться к IP, который по таблице выглядит как DC2, а на самом деле хочет DC4. Прокси открывает WebSocket к `kws2.web.telegram.org`, отправляет данные — сервер дропает соединение. Пользователь видит «прокси не настроен».
**Решение в v2:** не угадывать DC по IP. Telegram Desktop использует **obfuscated2** транспорт. Первые 64 байта — зашифрованный init-пакет. Внутри — настоящий DC ID.
Ключ: байты `[8..40]`, IV: `[40..56]`. Алгоритм: AES-256-CTR. DC ID: `i32` в байтах `[60..64]` расшифрованного пакета.
```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)
}
```
12 строк. Три крейта (`aes`, `ctr`, `cipher`). Зато DC определяется **точно**, а не «скорее всего».
IP-маппинг остался как fallback — на случай если init-пакет повреждён (что на практике не случается).
### Привязка к Windows
v1 использовала:
- `netsh` для смены DNS
- Хардкоженный путь к шрифтам Windows
- `taskkill` для остановки процессов
- `ipconfig /flushdns`
Ни одна из этих вещей не нужна для WebSocket-прокси. DNS менять не надо — `web.telegram.org` резолвится нормально. Шрифты — egui использует встроенные. Весь платформо-специфичный код был мусором.
**v2: 0 строк платформо-специфичного кода.** Один и тот же бинарник компилируется на Windows, macOS и Linux без единого `#[cfg(target_os)]`.
## macOS: почему это важно
Среди разработчиков, дизайнеров, людей из IT — процент маководов огромный. А инструментов для обхода блокировки Telegram на маке — ноль целых, ноль десятых.
- **GoodbyeDPI** — Windows only. Даже не обсуждается.
- **Zapret** — есть `tpws` для macOS, но это CLI. Нужно: `brew install`, `sudo`, правка конфигов, ручная настройка системного прокси через `networksetup`. Для техничных людей — ОК. Для остальных — нет.
- **VPN** — работает, но гонит весь трафик. Для одного Telegram — оверкилл за $5/мес.
TGLock v2: скачал бинарник, запустил, нажал кнопку. Всё. Никакого `brew`, никакого `sudo`, никаких конфигов.
На Apple Silicon (M1M4) — нативный ARM-бинарник, ~6 МБ. На Intel-маках — x86_64 билд. Оба собираются автоматически в GitHub Actions.
### Gatekeeper
Apple блокирует неподписанные приложения. Developer ID стоит $99/год. Для бесплатного open-source — не вариант. Решение стандартное:
```bash
xattr -cr ~/Downloads/tglock-macos-arm64
chmod +x ~/Downloads/tglock-macos-arm64
```
Две команды, один раз.
## LAN-режим: один прокси на всю квартиру
Это была самая частая просьба в ишью:
> «Цель: пустить все домашние устройства с Telegram через один такой прокси на компе в локальной сети»
В v1 прокси слушал `127.0.0.1:1080` — только локально. Устройства в сети не могли подключиться.
В v2 — чекбокс **LAN** в интерфейсе. Включаешь — прокси биндится на `0.0.0.0`. Все устройства в домашней сети могут использовать прокси.
```
Телефон ──┐
Планшет ───┤── SOCKS5 → 192.168.1.42:1080 ──► TGLock ──► WSS → DC
Ноутбук ───┘
```
Приложение автоматически определяет LAN IP и показывает его в интерфейсе. На телефоне в настройках Telegram: SOCKS5, адрес — IP компьютера, порт — тот что в приложении.
Один компьютер. Все устройства. Без VPN, без роутера, без конфигов.
### Настраиваемый порт
Ещё один ишью: «порт 1080 занят другим сервисом».
Теперь порт можно менять прямо в интерфейсе. По умолчанию 1080, но если занят — ставишь любой другой. Порт валидируется при старте, в настройках Telegram автоматически отображается текущий.
## Архитектура v2
Два файла. 350 строк. Всё.
### proxy.rs (~160 строк)
```
TcpListener (0.0.0.0 | 127.0.0.1 : port)
SOCKS5 handshake
├── IP ∈ Telegram? ──► read 64-byte init
│ │
│ ▼
│ dc_from_init (AES-256-CTR)
│ │
│ ▼
│ WSS kws{dc}.web.telegram.org
│ │
│ ▼
│ select! { ws ⟷ tcp }
└── IP ∉ Telegram? ──► direct TCP relay
```
Никаких абстракций, traits, generics. Прямолинейный async-код. Каждое соединение — один `tokio::spawn`, один `select!` цикл.
### main.rs (~190 строк)
GUI на egui. Тёмная тема (GitHub Dark). Статистика в реальном времени: активные соединения, WS-туннели, текущий DC, аптайм.
Кнопка «Настроить автоматически» — открывает Telegram через `tg://socks?server=...&port=...`. Один клик до рабочего Telegram.
## CI/CD: бинарники для всех
GitHub Actions при пуше тега `v*` собирает:
| Файл | Платформа |
|---|---|
| `tglock.exe` | Windows x64 |
| `tglock-macos-arm64` | macOS Apple Silicon |
| `tglock-macos-x64` | macOS Intel |
| `tglock-linux-x64` | Linux x64 |
Четыре платформы, один workflow, автоматические релизы. Скачал — запустил — работает.
```yaml
strategy:
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: tglock.exe
- os: macos-latest
target: aarch64-apple-darwin
artifact: tglock-macos-arm64
- os: macos-latest
target: x86_64-apple-darwin
artifact: tglock-macos-x64
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: tglock-linux-x64
```
## Что изменилось: v1 vs v2
| | v1 | v2 |
|---|---|---|
| DC detection | IP-маппинг (ненадёжен) | AES-256-CTR из init (точно) |
| Ping/Pong | Игнорировался → обрыв через 2 мин | `biased` select → мгновенный Pong |
| flush() | Нет → потеря данных | Явный flush после каждого write |
| WS timeout | Нет → бесконечное зависание | 10 секунд |
| Платформы | Windows | Windows, macOS, Linux |
| DNS | Менял системный DNS | Не трогает |
| Адаптеры | Определял сетевой адаптер (баг с VMware) | Не определяет, не нужно |
| Порт | Хардкод 1080 | Настраиваемый |
| LAN | Нет | Чекбокс, 0.0.0.0 |
| Файлов | 4 модуля + bat-скрипт | 2 файла |
| Строк | ~800 | ~350 |
Половину кода удалил. Стало стабильнее.
## Сравнение с альтернативами (2026)
| | GoodbyeDPI | Zapret | VPN | **TGLock v2** |
|---|---|---|---|---|
| Подход | Фрагментация | Desync | Туннель | WebSocket |
| IP-шейпинг | Не обходит | Не обходит | Обходит | **Обходит** |
| macOS | Нет | CLI | Да | **GUI** |
| Нужен сервер | Нет | Нет | Да | **Нет** |
| Весь трафик | Нет | Нет | Да | **Только Telegram** |
| LAN-шаринг | Нет | Можно настроить | Да | **Чекбокс** |
| Стоимость | 0₽ | 0₽ | $310/мес | **0₽** |
## Цифры v2
- **350** строк кода
- **2** файла
- **4** платформы (Win x64, macOS ARM64, macOS x64, Linux x64)
- **0** строк платформо-специфичного кода
- **0** серверов
- **0₽**
## Скачать
**[github.com/by-sonic/tglock](https://github.com/by-sonic/tglock)** → [Releases](https://github.com/by-sonic/tglock/releases/latest)
Или собрать:
```bash
git clone https://github.com/by-sonic/tglock.git
cd tglock
cargo build --release
```
macOS: после скачивания `xattr -cr tglock-macos-arm64 && chmod +x tglock-macos-arm64`
**P.S.** Для полного обхода блокировок (YouTube, Discord, Instagram и всё остальное) — **[RoseVPN](https://t.me/rosevpnru_bot)**.
---
*by sonic*
**Теги:** telegram, rust, websocket, macos, socks5, mtproto, обход блокировок, open-source, кроссплатформенность
**Хабы:** Rust · Open source · macOS · Сетевые технологии

View File

@@ -1,3 +1,17 @@
<!-- ROSEVPN-BANNER-START -->
<p align="center">
<a href="https://t.me/rosevpnru_bot">
<img alt="RoseVPN — быстрый VPN" src="https://img.shields.io/badge/%F0%9F%8C%B9%20RoseVPN-%D0%9F%D0%BE%D0%B4%D0%BA%D0%BB%D1%8E%D1%87%D0%B8%D1%82%D1%8C%D1%81%D1%8F%20%D0%B2%20Telegram-E63946?style=for-the-badge&logo=telegram&logoColor=white&labelColor=1a1a1a" height="40"/>
</a>
</p>
<p align="center">
<sub><b>Быстрый VPN с обходом YouTube, Discord, Instagram</b> · Бесплатный пробный период · Подключение за 30 секунд через бот <a href="https://t.me/rosevpnru_bot">@rosevpnru_bot</a></sub>
</p>
---
<!-- ROSEVPN-BANNER-END -->
<p align="center"> <p align="center">
<h1 align="center">TGLock</h1> <h1 align="center">TGLock</h1>
<p align="center"><b>Обход блокировки Telegram через WebSocket-туннель</b></p> <p align="center"><b>Обход блокировки Telegram через WebSocket-туннель</b></p>
@@ -69,7 +83,7 @@ cargo build --release
## VPN ## VPN
Для обхода блокировок **всех** приложений — **[by sonic VPN](https://t.me/bysonicvpn_bot)** Для обхода блокировок **всех** приложений — **[RoseVPN](https://t.me/rosevpnru_bot)**
## Лицензия ## Лицензия

View File

@@ -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);
}
} }
} }
@@ -164,13 +185,13 @@ impl eframe::App for App {
.inner_margin(egui::Margin::symmetric(12, 6)) .inner_margin(egui::Margin::symmetric(12, 6))
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.colored_label(ACCENT, egui::RichText::new("by sonic VPN").size(12.0).strong()); ui.colored_label(ACCENT, egui::RichText::new("RoseVPN").size(12.0).strong());
ui.colored_label(TEXT2, egui::RichText::new("Обход для всех приложений").size(11.0)); ui.colored_label(TEXT2, egui::RichText::new("Обход для всех приложений").size(11.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new( if ui.add(egui::Button::new(
egui::RichText::new("@bysonicvpn_bot").size(11.0).strong().color(ACCENT) egui::RichText::new("@rosevpnru_bot").size(11.0).strong().color(ACCENT)
).frame(false)).clicked() { ).frame(false)).clicked() {
let _ = open::that("https://t.me/bysonicvpn_bot"); let _ = open::that("https://t.me/rosevpnru_bot");
} }
}); });
}); });
@@ -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())
}

View File

@@ -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);