feat: implement WebSocket connection pooling and improve MTProto tran… (#229)

This commit is contained in:
Daniel Lavrushin 2026-05-31 20:36:55 +02:00 committed by GitHub
parent cf1d065c6a
commit 9861339741
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2752 additions and 391 deletions

View file

@ -1,5 +1,10 @@
# B4 - Bye Bye Big Bro
## [1.64.0] - 2026-05-31
- IMPROVED: **MTProto Telegram routing is more reliable, especially for media** - the WebSocket routing (bridge mode) now works on phones as well as desktop, loads media more reliably, and always prefers WebSocket regardless of the proxy server's transport setting. Data centers without a WebSocket edge no longer stall when the shared fallback domains get rate-limited. New "Cloudflare Worker" support adds a free, per-user relay (no domain to buy) for accounts where media, reactions, or stickers still fail to load - see Settings -> MTProto Proxy.
- FIXED: **Copy buttons did nothing when the Web UI was opened over plain HTTP** - copying commands, set exports, or system info silently failed on installs reached by LAN IP (for example `http://192.168.1.1`), because browsers only allow the modern clipboard API over HTTPS or localhost. The fallback copy path now works in those cases too.
## [1.63.0] - 2026-05-22
- FIXED: **Geo databases sometimes saved to a broken `b4` folder after install** - in rare cases the installer would record `b4/geosite.dat` instead of a full path like `/etc/b4/geosite.dat`, and the Web UI then refused to download new files. The installer now refuses non-absolute paths, b4 fixes any broken path it finds on startup, and the UI falls back to a safe default if the stored path looks wrong.

View file

@ -1,32 +1,143 @@
---
sidebar_position: 12
title: MTProto Proxy
title: MTProto / Telegram
---
# MTProto Proxy for Telegram
# Telegram with B4
B4 includes a built-in Telegram MTProto proxy that disguises traffic as a regular HTTPS connection to a popular website.
B4 can keep Telegram working on a censored network in two ways. They are independent and can run at the same time:
## Two deployment scenarios
1. **MTProto proxy server** - clients add B4 as an MTProto proxy inside the Telegram app (with a secret). Use this when a device should connect *to* B4, for example a phone on a cellular network reaching your home B4.
2. **Telegram over WebSocket (transparent bridge)** - a per-set routing mode that intercepts Telegram traffic from LAN devices and relays it for them. No in-app proxy and no secret. Use this to fix Telegram for every device behind B4 at once.
![mtproto](/img/mtproto/20260322135051.png)
Both rely on the same **Telegram upstream** settings (how B4 itself reaches Telegram's data centers), so that section is described once and applies to either mode.
### Scenario 1: B4 on a VPS abroad (simple)
| Aspect | Proxy server | WebSocket bridge |
| ----------------- | ----------------------------------------- | ------------------------- |
| Where configured | Settings → MTProto | A set's routing mode |
| Per-device setup | Add proxy + secret in Telegram | None (transparent) |
| Good for | One device reaching B4 (incl. remote/home)| All LAN devices at once |
| Needs MTProto on | Yes | No (independent) |
B4 runs on a server outside the censored zone. Users from inside Russia connect Telegram directly to the VPS.
---
```text
Phone (Russia) ──────▶ B4 on VPS ──────▶ Telegram
TSPU sees
"HTTPS to google.com"
(not blocked)
## Telegram upstream (shared)
Settings → **MTProto Proxy****Telegram upstream**. This controls how B4 reaches Telegram's data centers. It is used by the proxy server *and* by the WebSocket bridge mode, so it applies even when the proxy server is turned off.
### Transport mode
- **Direct TCP** - fastest. Use when the B4 host can reach `149.154.0.0/16` directly (e.g. a VPS abroad).
- **Auto (WebSocket → TCP)** - try WebSocket first via `kws*.web.telegram.org`, fall back to direct TCP. Recommended on censored networks.
- **WebSocket only** - strict WebSocket transport, no TCP fallback.
:::info
The transport-mode dropdown applies to the **proxy server** only. The **WebSocket bridge** routing mode always uses Auto (WebSocket-first with TCP fallback).
:::
### Cloudflare Worker domain (recommended fallback)
If media, reactions, or stickers fail to load, set a **Cloudflare Worker domain**. It is a free per-user WebSocket relay you host on your own Cloudflare account (`*.workers.dev`). B4 can reach any data center through it, so it rescues DCs with no native WebSocket edge (1, 3, 5) and connections throttled on the shared CF pool. The worker is tried after Telegram's own edge (so the fast native path still wins for DC 2/4) and before the shared CF pool.
Setup, in short:
1. Create a free Cloudflare account.
2. In **Compute → Workers & Pages**, create a Worker from the default template and deploy it.
3. Replace the worker code with the proxy script, then redeploy.
4. Copy the worker's `name-1234.username.workers.dev` domain into the **Cloudflare Worker domain** field. Comma-separate multiple workers.
Make sure `cloudflare.com`, `cloudflare.dev`, and `workers.dev` are reachable (not blocked) on your network.
The worker script and the full step-by-step are maintained by tg-ws-proxy: [CfWorker.md](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md). B4 dials the worker at `/apiws`, matching that script.
### CF proxy fallback
A rotating pool of Cloudflare-proxied domains used as a fallback when Telegram's native edge cannot reach a data center (notably DC 1, needed for media in foreign channels). The pool refreshes hourly.
### Testing
- **Test connection** probes DC 2 over the configured transport(s) and reports latency.
- **Test direct TCP** probes DC 2 over direct TCP, bypassing any DC Relay, to isolate whether a problem is the relay or Telegram itself.
---
## Option 1: MTProto proxy server
A Telegram proxy that clients connect to with a secret. B4 disguises the traffic as a regular HTTPS connection to a popular website.
![20260531200322](../../../../static/img/mtproto/20260531200322.png)
### Step 1: Configure B4
In the B4 web UI → **Settings****MTProto Proxy**:
1. **Enable MTProto Proxy** - turn it on
2. **Port** - listen port (recommended: `443`)
3. **Fake SNI Domain** - domain to impersonate (e.g. `storage.googleapis.com`)
4. Click **Generate Secret**
5. Copy the **Secret** value
6. Save settings and restart B4
Set the **Telegram upstream** transport (above) according to where B4 runs:
- **B4 on a VPS abroad** - Direct TCP. B4 reaches Telegram directly; leave DC Relay empty.
- **B4 on a router inside Russia** - Auto (WebSocket → TCP). B4 reaches Telegram over the WebSocket edge, so no VPS relay is required. If WebSocket is also blocked on your network, use a DC Relay (below).
### Step 2: Configure Telegram
1. Open **Telegram****Settings****Data and Storage** → **Proxy**
2. Tap **Add Proxy**
3. Choose **MTProto**
4. Fill in:
- **Server**: B4 IP or hostname (LAN IP for local devices; public IP or DDNS for remote use, with port forwarding)
- **Port**: the port from step 1
- **Secret**: the copied secret
5. Tap **Done** and enable the proxy
![telegra](/img/mtproto/20260322135130.png)
You can also use the **Share connection link** button to generate a `tg://proxy` link or QR code for another device.
---
## Option 2: Telegram over WebSocket (transparent bridge)
A per-set routing mode that fixes Telegram for every device behind B4, with no in-app proxy and no VPS. When a device connects to a Telegram data center, B4 transparently intercepts the session and relays it over Telegram's WebSocket edge (with Cloudflare fallback).
This mode runs on its own. The MTProto proxy server under Settings → MTProto does **not** need to be enabled.
### Setup
1. Create or open a set and give it the **`telegram`** target in both the geosite and geoip categories (so the set matches Telegram's domains and IP ranges).
2. In the set's **Routing** tab, enable routing and set **Routing mode** to **Telegram over WebSocket (built-in)**.
3. Choose the **source interfaces** (the LAN interfaces whose devices should be bridged). Leave empty to bridge all devices.
4. Save.
A minimal set for this mode:
```json
{
"name": "telegram-ws",
"targets": {
"geosite_categories": ["telegram"],
"geoip_categories": ["telegram"]
},
"enabled": true,
"routing": { "enabled": true, "mode": "mtproto-ws" }
}
```
Setup takes 2 minutes. No extra software needed.
The shared **Telegram upstream** settings (Settings → MTProto) apply here, so configure a Cloudflare Worker domain there if media fails to load.
### Scenario 2: B4 on a router inside Russia (with relay)
:::info Best-effort
Only TCP MTProto sessions are bridged. Voice calls and transports B4 cannot map to a data center fall open to a direct connection.
:::
B4 runs on a router or machine inside Russia. An additional VPS is required to forward traffic, because TSPU blocks all direct IP-level connections to Telegram servers.
---
## DC Relay (VPS + socat)
Use a DC Relay only when B4 runs inside the censored zone *and* the WebSocket transport is also blocked, so direct IP-level connections to Telegram must go through a VPS.
```text
Phone ──────▶ B4 (router) ──────▶ VPS ──────▶ Telegram
@ -35,96 +146,36 @@ Phone ──────▶ B4 (router) ──────▶ VPS ────
(not blocked) (not blocked)
```
The VPS only needs a simple TCP forwarder — no keys, no MTProto-specific software.
---
## Scenario 1: B4 on a VPS
### Step 1: Configure B4
In the B4 web UI → **Settings****General****MTProto Proxy**:
1. **Enable MTProto Proxy** — turn it on
2. **Port** — listen port (recommended: `443`)
3. **Fake SNI Domain** — domain to impersonate (e.g. `storage.googleapis.com`)
4. Click **Generate Secret**
5. Copy the **Secret** value
6. Save settings and restart B4
Leave **DC Relay** empty — B4 on the VPS reaches Telegram directly.
### Step 2: Configure Telegram
1. Open **Telegram****Settings****Data and Storage** → **Proxy**
2. Tap **Add Proxy**
3. Choose **MTProto**
4. Fill in:
- **Server**: VPS IP or hostname
- **Port**: the port from step 1
- **Secret**: the copied secret
5. Tap **Done** and enable the proxy
![telegra](/img/mtproto/20260322135130.png)
---
## Scenario 2: B4 on a router inside Russia
The VPS only needs a simple TCP forwarder (`socat`); no keys, no MTProto-specific software.
### Step 1: Install socat on the VPS
On any VPS abroad, install socat:
```bash
apt install -y socat
```
B4 will generate the actual forwarding commands in the next step. There's no point hardcoding IPs in this guide — Telegram periodically changes DC addresses and adds new ones (for example, the media DC `203`).
### Step 2: Set the DC Relay address
### Step 2: Configure B4
In **Settings****MTProto Proxy**, set **DC Relay** to the VPS address with the base port (e.g. `my-vps.com:7007`). The field appears when the transport mode is Direct TCP or Auto.
In the B4 web UI → **Settings****General****MTProto Proxy**:
With Auto + a DC Relay configured, relay TCP is tried first and WebSocket is used as the fallback.
1. **Enable MTProto Proxy** — turn it on
2. **Port** — listen port (e.g. `7002`)
3. **Fake SNI Domain** — domain to impersonate (e.g. `storage.googleapis.com`)
4. **DC Relay** — VPS address with the base port (e.g. `my-vps.com:7007`)
5. Click **Generate Secret**
6. Copy the **Secret** value
### Step 3: Get the socat commands
### Step 3: Get the socat commands for the VPS
Click the **?** button next to the **DC Relay** field. The "DC Relay socat setup" dialog opens, listing the current Telegram DCs and ready-to-run `socat` commands for each one (including the media DC).
Click **Copy all**, switch to the VPS, and run the commands.
Click the **?** button next to the **DC Relay** field. The "DC Relay socat setup" dialog lists the current Telegram DCs and ready-to-run `socat` commands for each one (including the media DC). Click **Copy all**, switch to the VPS, and run them.
:::info Why the helper
The DC list is fetched live from `getProxyConfig` Telegram's own published list. B4 computes the relay port as `base_port + |DC| - 1`. If Telegram adds a new DC or changes an IP, the helper shows the up-to-date commands without needing to update this guide.
The DC list is fetched live from `getProxyConfig` - Telegram's own published list. B4 computes the relay port as `base_port + |DC| - 1`. If Telegram adds a new DC or changes an IP, the helper shows up-to-date commands without needing to update this guide.
:::
:::warning VPS firewall
Open every port the helper shows on the VPS firewall (the "Open these ports on the VPS firewall" line at the bottom of the dialog). Currently this is typically 6 ports: five for the main DCs (15) and one for the media DC `203`.
Open every port the helper shows (the "Open these ports on the VPS firewall" line at the bottom of the dialog). This is typically 6 ports: five for the main DCs (1-5) and one for the media DC `203`.
:::
:::tip
To auto-start `socat`, add the commands to `/etc/rc.local` or create a systemd service.
:::
### Step 4: Save B4
Once socat is running on the VPS, click **Save** in B4 and restart B4.
### Step 5: Configure Telegram
1. Open **Telegram****Settings****Data and Storage** → **Proxy**
2. Tap **Add Proxy**
3. Choose **MTProto**
4. Fill in:
- **Server**: IP of the router/machine running B4
- **Port**: the port from step 2
- **Secret**: the copied secret
5. Tap **Done** and enable the proxy
---
## Choosing a fake SNI domain
@ -139,36 +190,44 @@ The domain should be:
If someone connects to the B4 port without the correct secret, B4 transparently forwards them to the real site (the one configured in Fake SNI). A scanner sees an ordinary site, not a proxy.
:::
---
## Troubleshooting
### Telegram shows "Connecting…"
- Make sure `socat` is running on the VPS and the ports are reachable (Scenario 2)
- Double-check the VPS address in the DC Relay field
- B4 logs should show `MTProto fake-TLS handshake OK` and `MTProto relay` lines
- If using the WebSocket transport, run **Test connection** to confirm B4 can reach a DC.
- If using a DC Relay, make sure `socat` is running on the VPS and the ports are reachable, and double-check the VPS address.
- B4 logs should show `MTProto fake-TLS handshake OK` and `MTProto relay` lines.
### Media, stickers, or reactions fail to load
- Set a **Cloudflare Worker domain** in the Telegram upstream settings. DC 1 (media for foreign channels) is the usual culprit, and the CF Worker / CF proxy fallback rescues it.
### Wrong secret
In the logs: `HMAC verification failed`
The secret in Telegram doesn't match the one configured in B4.
In the logs: `HMAC verification failed`. The secret in Telegram does not match the one configured in B4.
### Clock skew
In the logs: `timestamp out of range`
In the logs: `timestamp out of range`. The clocks on the device and the B4 machine disagree. Sync them (NTP).
The clocks on the device and the B4 machine disagree. Sync them (NTP).
### VPS unreachable (DC Relay)
### VPS unreachable
In the logs: `dial DC ... i/o timeout`
In the logs: `dial DC ... i/o timeout`.
- VPS is off, or `socat` is not running
- VPS firewall blocks inbound connections on the required ports
### No response from Telegram
In the logs: `DC->client: 0 bytes`
In the logs: `DC->client: 0 bytes`.
- If DC Relay is **not set**: Telegram servers are blocked by IP. You need a VPS relay (Scenario 2).
- If DC Relay **is set**: `socat` is not running on the VPS, or the wrong port was specified.
- Direct TCP and no relay: Telegram servers are blocked by IP. Switch the transport to Auto/WebSocket, or set up a DC Relay.
- DC Relay set: `socat` is not running on the VPS, or the wrong port was specified.
---
## Credits
The WebSocket transport and the Cloudflare Worker relay are inspired by [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy).

View file

@ -1,49 +1,75 @@
---
sidebar_position: 12
title: MTProto Прокси
title: MTProto / Telegram
---
# MTProto Прокси для Telegram
# Telegram с B4
B4 включает встроенный прокси для Telegram, который маскирует трафик под обычное HTTPS-соединение к популярному сайту.
B4 может сохранять работу Telegram в сети с цензурой двумя способами. Они независимы и могут работать одновременно:
## Два сценария использования
1. **MTProto прокси-сервер** - клиент добавляет B4 как MTProto-прокси в самом приложении Telegram (по секрету). Подходит, когда устройство должно подключаться *к* B4, например телефон в сотовой сети достучивается до домашнего B4.
2. **Telegram через WebSocket (прозрачный мост)** - режим маршрутизации на уровне сета, который перехватывает трафик Telegram от устройств в локальной сети и ретранслирует его за них. Без прокси в приложении и без секрета. Подходит, чтобы починить Telegram сразу для всех устройств за B4.
![mtproto](/img/mtproto/20260322135051.png)
Оба способа опираются на одни и те же настройки **Транспорт к Telegram** (как сам B4 достигает дата-центров Telegram), поэтому этот раздел описан один раз и относится к любому из режимов.
### Сценарий 1: B4 на VPS за границей (простой)
B4 установлен на сервере за пределами цензурированной зоны. Пользователи из России подключают Telegram напрямую к VPS.
```text
Телефон (Россия) ──────▶ B4 на VPS ──────▶ Telegram
ТСПУ видит
«HTTPS к google.com»
(не блокирует)
```
Настройка занимает 2 минуты. Дополнительное ПО не требуется.
### Сценарий 2: B4 на роутере внутри России (с relay)
B4 установлен на роутере или машине внутри России. Требуется дополнительный VPS для пересылки трафика, так как ТСПУ блокирует все прямые соединения к серверам Telegram по IP.
```text
Телефон ──────▶ B4 (роутер) ──────▶ VPS ──────▶ Telegram
ТСПУ видит ТСПУ видит
«HTTPS к google.com» «трафик к VPS»
(не блокирует) (не блокирует)
```
На VPS достаточно запустить простую пересылку трафика - без ключей и настроек.
| Параметр | Прокси-сервер | WebSocket-мост |
| ------------------------ | ----------------------------------------------------------- | ----------------------------------- |
| Где настраивается | Settings → MTProto | Режим маршрутизации сета |
| Настройка на устройстве | Добавить прокси и секрет в Telegram | Не нужна (прозрачно) |
| Для чего | Одно устройство достучивается до B4 (в т.ч. удалённо/домой) | Все устройства локальной сети сразу |
| Нужен включённый MTProto | Да | Нет (независим) |
---
## Сценарий 1: B4 на VPS
## Транспорт к Telegram (общий)
Settings → **MTProto Proxy****Telegram upstream**. Определяет, как B4 достигает дата-центров Telegram. Используется и прокси-сервером, и режимом WebSocket-моста, поэтому действует даже когда прокси-сервер выключен.
### Режим транспорта
- **Прямой TCP** - самый быстрый. Подходит, если хост с B4 может напрямую достучаться до `149.154.0.0/16` (например, VPS за границей).
- **Авто (WebSocket → TCP)** - сначала WebSocket через `kws*.web.telegram.org`, при неудаче - прямой TCP. Рекомендуется для сетей с цензурой.
- **Только WebSocket** - жёсткий WebSocket-транспорт, без TCP-резерва.
:::info
Выбор режима транспорта касается только **прокси-сервера**. Режим маршрутизации **WebSocket-мост** всегда использует Авто (сначала WebSocket, затем TCP).
:::
### Домен Cloudflare Worker (рекомендуемый резерв)
Если не загружаются медиа, реакции или стикеры, укажите **домен Cloudflare Worker**. Это бесплатный персональный WebSocket-релей, который вы разворачиваете на собственном аккаунте Cloudflare (`*.workers.dev`). Через него B4 достаёт любой дата-центр, поэтому он выручает DC без родного WebSocket-узла (1, 3, 5) и соединения, упёршиеся в лимиты общего пула CF. Воркер пробуется после родного узла Telegram (чтобы для DC 2/4 побеждал быстрый родной путь) и перед общим пулом CF.
Коротко о настройке:
1. Заведите бесплатный аккаунт Cloudflare.
2. В **Compute → Workers & Pages** создайте Worker из шаблона по умолчанию и задеплойте его.
3. Замените код воркера на скрипт прокси и задеплойте снова.
4. Скопируйте домен воркера вида `name-1234.username.workers.dev` в поле **домен Cloudflare Worker**. Несколько воркеров указывайте через запятую.
Убедитесь, что `cloudflare.com`, `cloudflare.dev` и `workers.dev` доступны (не заблокированы) в вашей сети.
Скрипт воркера и подробная пошаговая инструкция поддерживаются проектом tg-ws-proxy: [CfWorker.md](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md). B4 обращается к воркеру по пути `/apiws`, как и этот скрипт.
### Резерв через CF-прокси
Ротируемый пул проксированных через Cloudflare доменов, который используется как резерв, когда родной узел Telegram не может достучаться до дата-центра (особенно DC 1, нужного для медиа во внешних каналах). Пул обновляется раз в час.
### Проверка
- **Проверить соединение** пробует достучаться до DC 2 настроенным транспортом (или транспортами) и измеряет задержку.
- **Проверить прямой TCP** пробует DC 2 по прямому TCP, минуя DC Relay, чтобы понять, в чём проблема - в реле или в самом Telegram.
---
## Вариант 1: MTProto прокси-сервер
Telegram-прокси, к которому клиенты подключаются по секрету. B4 маскирует трафик под обычное HTTPS-соединение к популярному сайту.
![20260531200322](../../../../static/img/mtproto/20260531200322.png)
### Шаг 1: Настройка B4
В веб-интерфейсе B4 → **Settings****General****MTProto Proxy**:
В веб-интерфейсе B4 → **Settings****MTProto Proxy**:
1. **Enable MTProto Proxy** - включить
2. **Port** - порт для подключений (рекомендуется `443`)
@ -52,7 +78,10 @@ B4 установлен на роутере или машине внутри Р
5. Скопировать значение из поля **Secret**
6. Сохранить настройки и перезапустить B4
Поле **DC Relay** оставить пустым - B4 на VPS подключается к Telegram напрямую.
Режим **Транспорт к Telegram** (см. выше) выбирается по тому, где работает B4:
- **B4 на VPS за границей** - Прямой TCP. B4 достигает Telegram напрямую; поле DC Relay оставить пустым.
- **B4 на роутере внутри России** - Авто (WebSocket → TCP). B4 достигает Telegram через WebSocket-узел, и VPS-реле не требуется. Если WebSocket в вашей сети тоже заблокирован, используйте DC Relay (ниже).
### Шаг 2: Настройка Telegram
@ -60,38 +89,78 @@ B4 установлен на роутере или машине внутри Р
2. Нажать **Добавить прокси**
3. Выбрать тип **MTProto**
4. Заполнить:
- **Сервер**: IP-адрес или домен VPS
- **Сервер**: IP-адрес или домен B4 (локальный IP для устройств в сети; публичный IP или DDNS для удалённого доступа, с пробросом портов)
- **Порт**: порт из шага 1
- **Секрет**: скопированный секрет
5. Нажать **Готово** и включить прокси
![telegra](/img/mtproto/20260322135130.png)
![telegra](/img/mtproto/20260322135130.png)
Можно также воспользоваться кнопкой **Share connection link**, чтобы сгенерировать ссылку `tg://proxy` или QR-код для другого устройства.
---
## Сценарий 2: B4 на роутере внутри России
## Вариант 2: Telegram через WebSocket (прозрачный мост)
Режим маршрутизации на уровне сета, который чинит Telegram для всех устройств за B4 - без прокси в приложении и без VPS. Когда устройство подключается к дата-центру Telegram, B4 прозрачно перехватывает сессию и ретранслирует её через WebSocket-узел Telegram (с запасным вариантом через Cloudflare).
Этот режим работает самостоятельно. MTProto прокси-сервер в разделе Settings → MTProto включать **не** требуется.
### Настройка
1. Создайте или откройте сет и задайте ему цели **`telegram`** в категориях geosite и geoip (чтобы сет совпадал и с доменами, и с диапазонами IP Telegram).
2. На вкладке **Routing** сета включите маршрутизацию и выберите **Режим маршрутизации****Telegram через WebSocket (встроенный)**.
3. Выберите **source-интерфейсы** (интерфейсы локальной сети, чьи устройства нужно переправлять). Если не выбрать ни одного, под мост попадают все устройства.
4. Сохраните.
Минимальный сет для этого режима:
```json
{
"name": "telegram-ws",
"targets": {
"geosite_categories": ["telegram"],
"geoip_categories": ["telegram"]
},
"enabled": true,
"routing": { "enabled": true, "mode": "mtproto-ws" }
}
```
Здесь действуют общие настройки **Транспорт к Telegram** (Settings → MTProto), поэтому при проблемах с загрузкой медиа укажите там домен Cloudflare Worker.
:::info Best-effort
Переправляются только TCP MTProto-сессии. Голосовые звонки и транспорты, для которых B4 не может определить дата-центр, идут напрямую (fail-open).
:::
---
## DC Relay (VPS + socat)
DC Relay нужен только когда B4 работает внутри цензурированной зоны *и* WebSocket-транспорт тоже заблокирован, так что прямые соединения к Telegram по IP приходится пускать через VPS.
```text
Телефон ──────▶ B4 (роутер) ──────▶ VPS ──────▶ Telegram
ТСПУ видит ТСПУ видит
«HTTPS к google.com» «трафик к VPS»
(не блокирует) (не блокирует)
```
На VPS достаточно простой пересылки TCP (`socat`) - без ключей и MTProto-специфичного ПО.
### Шаг 1: Установка socat на VPS
На любом VPS за границей установить socat:
```bash
apt install -y socat
```
Команды пересылки сгенерирует сам B4 на следующем шаге — жёстко прописывать IP в инструкции бессмысленно, потому что Telegram периодически меняет адреса DC и добавляет новые (например, медиа-DC `203`).
### Шаг 2: Указать адрес DC Relay
### Шаг 2: Настройка B4
В **Settings****MTProto Proxy** укажите в поле **DC Relay** адрес VPS с базовым портом (например `my-vps.com:7007`). Поле появляется, когда режим транспорта - Прямой TCP или Авто.
В веб-интерфейсе B4 → **Settings****General****MTProto Proxy**:
При Авто с настроенным DC Relay сначала пробуется реле по TCP, а WebSocket используется как резерв.
1. **Enable MTProto Proxy** — включить
2. **Port** — порт для подключений (например `7002`)
3. **Fake SNI Domain** — домен для маскировки (например `storage.googleapis.com`)
4. **DC Relay** — адрес VPS с базовым портом (например `my-vps.com:7007`)
5. Нажать **Generate Secret**
6. Скопировать значение из поля **Secret**
### Шаг 3: Получить команды socat для VPS
### Шаг 3: Получить команды socat
Нажать кнопку **?** рядом с полем **DC Relay**. Откроется окно «Настройка socat для DC Relay» со списком текущих серверов Telegram и готовыми командами `socat` для каждого DC, включая медиа-DC.
@ -100,32 +169,17 @@ apt install -y socat
Нажать **Копировать всё**, перейти на VPS и выполнить вставленные команды.
:::info Почему через помощник
Список DC Telegram берётся напрямую из `getProxyConfig` это официальный список, который обновляется на стороне Telegram. B4 рассчитывает порт relay по формуле `базовый_порт + |DC| - 1`. Если Telegram добавит новый DC или поменяет IP, помощник покажет актуальные команды без правок инструкции.
Список DC Telegram берётся напрямую из `getProxyConfig` - это официальный список, который обновляется на стороне Telegram. B4 рассчитывает порт relay по формуле `базовый_порт + |DC| - 1`. Если Telegram добавит новый DC или поменяет IP, помощник покажет актуальные команды без правок инструкции.
:::
:::warning Firewall на VPS
Открыть на VPS все порты, которые показывает помощник (строка «Откройте эти порты в firewall VPS» внизу окна). Сейчас это обычно 6 портов: пять для основных DC (15) и один для медиа-DC `203`.
Открыть на VPS все порты, которые показывает помощник (строка «Откройте эти порты в firewall VPS» внизу окна). Сейчас это обычно 6 портов: пять для основных DC (1-5) и один для медиа-DC `203`.
:::
:::tip
Для автозапуска `socat` добавить команды в `/etc/rc.local` или создать systemd-сервис.
:::
### Шаг 4: Сохранить настройки B4
После того как socat запущен на VPS, в B4 нажать **Save** и перезапустить B4.
### Шаг 5: Настройка Telegram
1. [Открыть](https://core.telegram.org/proxy#adding-a-proxy) **Telegram****Настройки****Данные и память** → **Прокси**
2. Нажать **Добавить прокси**
3. Выбрать тип **MTProto**
4. Заполнить:
- **Сервер**: IP-адрес роутера или машины с B4
- **Порт**: порт из шага 2
- **Секрет**: скопированный секрет
5. Нажать **Готово** и включить прокси
---
## Выбор домена для маскировки
@ -140,36 +194,44 @@ apt install -y socat
При подключении к порту B4 без правильного секрета - B4 прозрачно перенаправляет на настоящий сайт (указанный в Fake SNI). Сканер видит обычный сайт, а не прокси.
:::
## Что-то пошло не так, памагити
---
### Telegram показывает "Подключение..."
## Устранение неполадок
- Убедиться, что `socat` запущен на VPS и порты доступны (сценарий 2)
- Проверить правильность адреса VPS в поле DC Relay
- В логах B4 должны быть строки `MTProto fake-TLS handshake OK` и `MTProto relay`
### Telegram показывает «Подключение…»
- Если используется WebSocket-транспорт, нажмите **Проверить соединение**, чтобы убедиться, что B4 достаёт DC.
- Если используется DC Relay, убедитесь, что `socat` запущен на VPS и порты доступны, и проверьте адрес VPS.
- В логах B4 должны быть строки `MTProto fake-TLS handshake OK` и `MTProto relay`.
### Не загружаются медиа, стикеры или реакции
- Укажите **домен Cloudflare Worker** в настройках «Транспорт к Telegram». Обычно виноват DC 1 (медиа во внешних каналах), и резерв через CF Worker / CF-прокси его вытягивает.
### Неправильный секрет
В логах: `HMAC verification failed`
Секрет в Telegram не совпадает с секретом в B4.
В логах: `HMAC verification failed`. Секрет в Telegram не совпадает с секретом в B4.
### Расхождение времени
В логах: `timestamp out of range`
В логах: `timestamp out of range`. Часы на устройстве и на машине с B4 расходятся. Необходимо синхронизировать время (NTP).
Часы на устройстве и на машине с B4 расходятся. Необходимо синхронизировать время.
### VPS недоступен (DC Relay)
### VPS недоступен
В логах: `dial DC ... i/o timeout`
В логах: `dial DC ... i/o timeout`.
- VPS выключен или `socat` не запущен
- Firewall на VPS блокирует входящие соединения на нужных портах
### Нет ответа от Telegram
В логах: `DC->client: 0 bytes`
В логах: `DC->client: 0 bytes`.
- Если DC Relay **не настроен**: серверы Telegram заблокированы по IP. Необходимо настроить VPS relay (придется использовать только сценарий 2).
- Если DC Relay **настроен**: `socat` на VPS не запущен или указан неправильный порт.
- Прямой TCP и без реле: серверы Telegram заблокированы по IP. Переключите транспорт на Авто/WebSocket или настройте DC Relay.
- DC Relay настроен: `socat` на VPS не запущен или указан неправильный порт.
---
## Благодарности
WebSocket-транспорт и релей через Cloudflare Worker вдохновлены проектом [tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy).

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View file

@ -244,6 +244,8 @@ var DefaultConfig = Config{
FakeSNI: "storage.googleapis.com",
UpstreamMode: "auto",
WSEndpointHost: "149.154.167.220",
CFProxyEnabled: true,
CFProxyURL: "https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt",
},
Logging: Logging{

View file

@ -61,6 +61,21 @@ var migrationRegistry = map[int]MigrationFunc{
37: migrateV37to38, // Add MTProto upstream transport (WS) fields
38: migrateV38to39, // Add system.memory_limit
39: migrateV39to40, // Add geo auto_update config
40: migrateV40to41, // Add MTProto CF-proxy fallback config
41: migrateV41to42, // Add MTProto CF Worker domain config
}
func migrateV41to42(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v41->v42: Adding MTProto CF Worker domain config")
c.System.MTProto.CFWorkerDomain = ""
return nil
}
func migrateV40to41(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v40->v41: Adding MTProto CF-proxy fallback config")
c.System.MTProto.CFProxyEnabled = true
c.System.MTProto.CFProxyURL = DefaultConfig.System.MTProto.CFProxyURL
return nil
}
func migrateV39to40(c *Config, _ map[string]interface{}) error {

View file

@ -17,8 +17,13 @@ const FakePayloadAutoQUIC = "@quic_initial"
const (
RoutingModeInterface = "interface"
RoutingModeProxy = "proxy"
RoutingModeMTProtoWS = "mtproto-ws"
)
func RoutingUsesTProxy(mode string) bool {
return mode == RoutingModeProxy || mode == RoutingModeMTProtoWS
}
const (
FakePayloadRandom = iota
FakePayloadCustom
@ -226,6 +231,9 @@ type MTProtoConfig struct {
UpstreamMode string `json:"upstream_mode"`
WSCustomDomain string `json:"ws_custom_domain"`
WSEndpointHost string `json:"ws_endpoint_host"`
CFProxyEnabled bool `json:"cfproxy_enabled"` // enable Cloudflare-proxied fallback WS domains (rescues DCs the network blocks)
CFProxyURL string `json:"cfproxy_url"` // URL to refresh CF-proxy domain list; empty = built-in default
CFWorkerDomain string `json:"cfworker_domain"` // user's Cloudflare Worker domain(s) (workers.dev), comma-separated; free per-user WS relay tried before the shared CF pool
}
type Socks5Config struct {

View file

@ -120,7 +120,7 @@ func (c *Config) Validate() error {
switch set.Routing.Mode {
case "":
set.Routing.Mode = RoutingModeInterface
case RoutingModeProxy, RoutingModeInterface:
case RoutingModeProxy, RoutingModeInterface, RoutingModeMTProtoWS:
default:
v.addf(fmt.Sprintf("sets[%d].routing.mode", setIdx), "invalid_routing_mode", map[string]any{"set": set.Name, "mode": set.Routing.Mode}, "set %q: unknown routing mode %q", set.Name, set.Routing.Mode)
return v.result()

View file

@ -16,8 +16,8 @@ import (
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/nfq"
"github.com/daniellavrushin/b4/watchdog"
"github.com/daniellavrushin/b4/utils"
"github.com/daniellavrushin/b4/watchdog"
"golang.org/x/sys/unix"
)
@ -33,13 +33,16 @@ type ConfigRefresher interface {
}
var (
globalPool *nfq.Pool
globalSocks5Server ConfigRefresher
tablesRefreshFunc func() error
routingSyncFunc func(*config.Config)
discoveryRuntime *discovery.Runtime
globalWatchdog *watchdog.Watchdog
globalAIManager *ai.Manager
globalPool *nfq.Pool
globalSocks5Server ConfigRefresher
globalMTProtoServer ConfigRefresher
globalMTProtoBridge ConfigRefresher
tablesRefreshFunc func() error
routingSyncFunc func(*config.Config)
mtprotoCFRefreshFunc func(*config.Config)
discoveryRuntime *discovery.Runtime
globalWatchdog *watchdog.Watchdog
globalAIManager *ai.Manager
)
func SetAIManager(m *ai.Manager) {
@ -68,6 +71,14 @@ func SetSocks5Server(s ConfigRefresher) {
globalSocks5Server = s
}
func SetMTProtoServer(s ConfigRefresher) {
globalMTProtoServer = s
}
func SetMTProtoBridge(s ConfigRefresher) {
globalMTProtoBridge = s
}
func NewAPIHandler(cfgPtr *atomic.Pointer[config.Config]) *API {
cfg := cfgPtr.Load()
// Initialize geodata manager
@ -112,7 +123,7 @@ func NewAPIHandler(cfgPtr *atomic.Pointer[config.Config]) *API {
cfgPtr: cfgPtr,
geodataManager: geodataManager,
discoveryRT: discoveryRuntime,
asnStore: config.NewAsnStore(cfg.ConfigPath),
asnStore: config.NewAsnStore(cfg.ConfigPath),
}
}
func (api *API) RegisterEndpoints(mux *http.ServeMux, cfgPtr *atomic.Pointer[config.Config]) {
@ -159,6 +170,10 @@ func SetRoutingSyncFunc(fn func(*config.Config)) {
routingSyncFunc = fn
}
func SetMTProtoCFRefreshFunc(fn func(*config.Config)) {
mtprotoCFRefreshFunc = fn
}
func SetDiscoveryRuntime(rt *discovery.Runtime) {
discoveryRuntime = rt
}

View file

@ -341,7 +341,6 @@ func (a *API) updateConfig(w http.ResponseWriter, r *http.Request) {
_ = enc.Encode(response)
}
func (a *API) saveAndPushConfig(newCfg *config.Config) error {
if err := newCfg.Validate(); err != nil {
@ -368,6 +367,18 @@ func (a *API) saveAndPushConfig(newCfg *config.Config) error {
globalSocks5Server.UpdateConfig(newCfg)
}
if globalMTProtoServer != nil {
globalMTProtoServer.UpdateConfig(newCfg)
}
if globalMTProtoBridge != nil {
globalMTProtoBridge.UpdateConfig(newCfg)
}
if mtprotoCFRefreshFunc != nil {
mtprotoCFRefreshFunc(newCfg)
}
err := newCfg.SaveToFile(newCfg.ConfigPath)
if err != nil {
return fmt.Errorf("failed to save config to file: %v", err)

View file

@ -22,7 +22,7 @@ func (api *API) RegisterMTProtoApi() {
// @Tags MTProto
// @Accept json
// @Produce json
// @Param body body object false "optional overrides: upstream_mode, ws_custom_domain, dc"
// @Param body body object false "optional overrides: upstream_mode, ws_custom_domain, ws_endpoint_host, cfworker_domain, cfproxy_enabled, dc_relay, dc"
// @Success 200 {object} map[string]interface{}
// @Security BearerAuth
// @Router /mtproto/test-ws [post]
@ -35,6 +35,8 @@ func (api *API) handleMTProtoTestWS(w http.ResponseWriter, r *http.Request) {
UpstreamMode string `json:"upstream_mode"`
WSCustomDomain *string `json:"ws_custom_domain"`
WSEndpointHost *string `json:"ws_endpoint_host"`
CFWorkerDomain *string `json:"cfworker_domain"`
CFProxyEnabled *bool `json:"cfproxy_enabled"`
DCRelay *string `json:"dc_relay"`
DC int `json:"dc"`
}
@ -61,6 +63,12 @@ func (api *API) handleMTProtoTestWS(w http.ResponseWriter, r *http.Request) {
if req.WSEndpointHost != nil {
probeCfg.WSEndpointHost = *req.WSEndpointHost
}
if req.CFWorkerDomain != nil {
probeCfg.CFWorkerDomain = *req.CFWorkerDomain
}
if req.CFProxyEnabled != nil {
probeCfg.CFProxyEnabled = *req.CFProxyEnabled
}
if req.DCRelay != nil {
probeCfg.DCRelay = *req.DCRelay
}
@ -219,12 +227,12 @@ func (api *API) updateMTProtoConfig(w http.ResponseWriter, r *http.Request) {
return
}
cfg := api.getCfg()
cfg.System.MTProto = req
cur := api.getCfg()
newCfg := cur.Clone()
newCfg.System.MTProto = req
if err := cfg.SaveToFile(cfg.ConfigPath); err != nil {
log.Errorf("Failed to save MTProto config: %v", err)
writeJsonError(w, http.StatusInternalServerError, "Failed to save configuration")
if err := api.saveAndPushConfig(newCfg); err != nil {
writeAPIError(w, err)
return
}
@ -232,6 +240,6 @@ func (api *API) updateMTProtoConfig(w http.ResponseWriter, r *http.Request) {
sendResponse(w, map[string]interface{}{
"success": true,
"message": "MTProto configuration updated. Restart required for changes to take effect.",
"message": "MTProto configuration updated and applied.",
})
}

View file

@ -28,14 +28,21 @@ export const TrafficRouting = ({
}: TrafficRoutingProps) => {
const { t } = useTranslation();
const routing = config.routing;
const mode: RoutingMode = routing.mode === "proxy" ? "proxy" : "interface";
const mode: RoutingMode =
routing.mode === "proxy"
? "proxy"
: routing.mode === "mtproto-ws"
? "mtproto-ws"
: "interface";
const isProxy = mode === "proxy";
const isMTProtoWS = mode === "mtproto-ws";
const isInterface = mode === "interface";
const selectedIfaceAvailable = availableIfaces.includes(
routing.egress_interface,
);
const shouldShowUnavailableSelected = Boolean(
!isProxy && routing.egress_interface && !selectedIfaceAvailable,
isInterface && routing.egress_interface && !selectedIfaceAvailable,
);
const toggleSourceIface = (iface: string) => {
@ -61,6 +68,8 @@ export const TrafficRouting = ({
upstream.host && upstream.port
? `${upstream.host}:${upstream.port}`
: t("sets.routing.flowNoUpstream");
} else if (isMTProtoWS) {
flowDestination = t("sets.routing.flowMTProtoWS");
} else {
flowDestination =
routing.egress_interface || t("sets.routing.flowNoOutput");
@ -75,7 +84,7 @@ export const TrafficRouting = ({
onChange={(checked: boolean) => onChange("routing.enabled", checked)}
description={t("sets.routing.enableDesc")}
disabled={
!isProxy && availableIfaces.length === 0 && !routing.enabled
isInterface && availableIfaces.length === 0 && !routing.enabled
}
/>
</Grid>
@ -94,7 +103,15 @@ export const TrafficRouting = ({
{t("sets.routing.modeInterface")}
</MenuItem>
<MenuItem value="proxy">{t("sets.routing.modeProxy")}</MenuItem>
<MenuItem value="mtproto-ws">
{t("sets.routing.modeMTProtoWS")}
</MenuItem>
</B4TextField>
{isMTProtoWS && (
<B4Alert severity="info" sx={{ mt: 2 }}>
{t("sets.routing.mtprotoWsNote")}
</B4Alert>
)}
</Grid>
<Grid size={{ xs: 12 }}>
@ -193,7 +210,9 @@ export const TrafficRouting = ({
>
{isProxy
? t("sets.routing.flowProxyCaption")
: t("sets.routing.flowCaption")}
: isMTProtoWS
? t("sets.routing.flowMTProtoWSCaption")
: t("sets.routing.flowCaption")}
</Typography>
</Box>
</Grid>
@ -201,7 +220,9 @@ export const TrafficRouting = ({
<B4Hint>
{isProxy
? t("sets.routing.howItWorksProxy")
: t("sets.routing.howItWorks")}
: isMTProtoWS
? t("sets.routing.howItWorksMTProtoWS")
: t("sets.routing.howItWorks")}
</B4Hint>
<Grid size={{ xs: 12 }}>
@ -240,7 +261,7 @@ export const TrafficRouting = ({
})}
</Box>
{availableIfaces.length === 0 && !isProxy && (
{availableIfaces.length === 0 && isInterface && (
<B4Alert severity="warning" sx={{ mt: 2 }}>
{t("sets.routing.noInterfaces")}
</B4Alert>
@ -255,11 +276,15 @@ export const TrafficRouting = ({
)}
<B4Hint sx={{ mt: 2 }}>
{isProxy ? t("sets.routing.infoProxy") : t("sets.routing.info")}
{isProxy
? t("sets.routing.infoProxy")
: isMTProtoWS
? t("sets.routing.infoMTProtoWS")
: t("sets.routing.info")}
</B4Hint>
</Grid>
{!isProxy && (
{isInterface && (
<Grid size={{ xs: 12, md: 6 }}>
<B4TextField
label={t("sets.routing.outputInterface")}

View file

@ -6,6 +6,7 @@ import {
CircularProgress,
IconButton,
InputAdornment,
Link,
Tooltip,
Typography,
Chip,
@ -169,6 +170,8 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
upstream_mode: config.system.mtproto?.upstream_mode || "auto",
ws_custom_domain: config.system.mtproto?.ws_custom_domain || "",
ws_endpoint_host: config.system.mtproto?.ws_endpoint_host || "",
cfworker_domain: config.system.mtproto?.cfworker_domain || "",
cfproxy_enabled: config.system.mtproto?.cfproxy_enabled ?? true,
dc: 2,
...overrides,
}),
@ -218,7 +221,11 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
description={t("settings.MTProto.description")}
icon={<ConnectionIcon />}
>
<B4FormGroup label={t("settings.MTProto.settings")} columns={2}>
<B4FormGroup
label={t("settings.MTProto.settings")}
description={t("settings.MTProto.serverDesc")}
columns={2}
>
<B4Switch
label={t("settings.MTProto.enable")}
checked={config.system.mtproto?.enabled ?? false}
@ -227,11 +234,6 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
}
description={t("settings.MTProto.enableDesc")}
/>
{config.system.mtproto?.enabled && (
<B4Alert severity="warning">
{t("settings.MTProto.restartNote")}
</B4Alert>
)}
<B4TextField
label={t("settings.MTProto.bindAddress")}
value={config.system.mtproto?.bind_address || "0.0.0.0"}
@ -314,9 +316,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
</B4FormGroup>
{(() => {
const mode = config.system.mtproto?.upstream_mode || "auto";
const enabled = !!config.system.mtproto?.enabled;
const showDcRelay =
!!dcRelay || mode === "tcp" || mode === "auto";
const showDcRelay = !!dcRelay || mode === "tcp" || mode === "auto";
return (
<B4FormGroup
label={t("settings.MTProto.upstreamTitle")}
@ -329,19 +329,18 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
onChange={(e) =>
onChange("system.mtproto.upstream_mode", String(e.target.value))
}
disabled={!enabled}
options={[
{ value: "tcp", label: t("settings.MTProto.upstreamTcp") },
{ value: "auto", label: t("settings.MTProto.upstreamAuto") },
{ value: "ws", label: t("settings.MTProto.upstreamWs") },
]}
helperText={
helperText={`${
mode === "auto" && dcRelay
? t("settings.MTProto.upstreamAutoRelayDesc")
: t(
`settings.MTProto.upstream${upstreamDescSuffix(mode)}Desc`,
)
}
} ${t("settings.MTProto.upstreamBridgeNote")}`}
/>
{showDcRelay && (
<B4TextField
@ -351,7 +350,6 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
onChange("system.mtproto.dc_relay", e.target.value)
}
placeholder="vps-ip:7007"
disabled={!enabled}
helperText={t("settings.MTProto.dcRelayHelp")}
selectOnFocus
slotProps={{
@ -365,7 +363,6 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
<IconButton
size="small"
onClick={openRelayHelp}
disabled={!enabled}
sx={{ px: 0 }}
>
<HelpOutlineIcon fontSize="small" />
@ -378,29 +375,55 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
}}
/>
)}
{mode !== "tcp" && (
<B4TextField
label={t("settings.MTProto.cfWorkerDomain")}
value={config.system.mtproto?.cfworker_domain || ""}
onChange={(e) =>
onChange("system.mtproto.cfworker_domain", e.target.value)
}
placeholder="my-worker-1234.username.workers.dev"
helperText={t("settings.MTProto.cfWorkerDomainHelp")}
selectOnFocus
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end" sx={{ mr: -0.5 }}>
<Tooltip title={t("settings.MTProto.cfWorkerSetup")}>
<span style={{ display: "inline-flex" }}>
<IconButton
size="small"
component="a"
href="https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
target="_blank"
rel="noreferrer"
sx={{ px: 0 }}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</InputAdornment>
),
},
}}
/>
<B4Switch
label={t("settings.MTProto.cfProxyEnabled")}
checked={config.system.mtproto?.cfproxy_enabled ?? true}
onChange={(checked: boolean) =>
onChange("system.mtproto.cfproxy_enabled", checked)
}
description={t("settings.MTProto.cfProxyEnabledHelp")}
/>
{config.system.mtproto?.cfproxy_enabled !== false && (
<B4TextField
label={t("settings.MTProto.wsCustomDomain")}
value={config.system.mtproto?.ws_custom_domain || ""}
label={t("settings.MTProto.cfProxyURL")}
value={config.system.mtproto?.cfproxy_url || ""}
onChange={(e) =>
onChange("system.mtproto.ws_custom_domain", e.target.value)
onChange("system.mtproto.cfproxy_url", e.target.value)
}
placeholder="your-domain.com"
disabled={!enabled}
helperText={t("settings.MTProto.wsCustomDomainHelp")}
selectOnFocus
/>
)}
{mode !== "tcp" && (
<B4TextField
label={t("settings.MTProto.wsEndpointHost")}
value={config.system.mtproto?.ws_endpoint_host || ""}
onChange={(e) =>
onChange("system.mtproto.ws_endpoint_host", e.target.value)
}
placeholder="149.154.167.220"
disabled={!enabled}
helperText={t("settings.MTProto.wsEndpointHostHelp")}
placeholder="https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt"
helperText={t("settings.MTProto.cfProxyURLHelp")}
selectOnFocus
/>
)}
@ -417,9 +440,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
)
}
onClick={() => void handleTestWS()}
disabled={
!config.system.mtproto?.enabled || wsTesting !== null
}
disabled={wsTesting !== null}
>
{wsTesting === "configured"
? t("settings.MTProto.testWsRunning")
@ -436,9 +457,7 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
) : undefined
}
onClick={() => void handleTestDirectTCP()}
disabled={
!config.system.mtproto?.enabled || wsTesting !== null
}
disabled={wsTesting !== null}
>
{wsTesting === "direct"
? t("settings.MTProto.testWsRunning")
@ -501,6 +520,22 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
</B4FormGroup>
);
})()}
<Typography variant="caption">
{t("settings.MTProto.credit")}{" "}
<Link
href="https://github.com/Flowseal/tg-ws-proxy"
target="_blank"
rel="noreferrer"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.25,
}}
>
tg-ws-proxy
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
</Typography>
<B4Dialog
open={shareOpen}
onClose={() => setShareOpen(false)}

View file

@ -22,6 +22,7 @@ import {
ApiIcon,
BackupIcon,
CaptureIcon,
ConnectionIcon,
CoreIcon,
DiscoveryIcon,
DomainIcon,
@ -83,6 +84,7 @@ enum TABS {
GENERAL = 0,
DOMAINS,
DISCOVERY,
MTPROTO,
API,
PAYLOADS,
BACKUP,
@ -127,6 +129,14 @@ export function SettingsPage() {
description: t("settings.tabs.discoveryDesc"),
requiresRestart: false,
},
{
id: TABS.MTPROTO,
path: "mtproto",
label: t("settings.tabs.mtproto"),
icon: <ConnectionIcon />,
description: t("settings.tabs.mtprotoDesc"),
requiresRestart: false,
},
{
id: TABS.API,
path: "api",
@ -199,8 +209,6 @@ export function SettingsPage() {
JSON.stringify(originalConfig.system.web_server) ||
JSON.stringify(config.system.socks5) !==
JSON.stringify(originalConfig.system.socks5) ||
JSON.stringify(config.system.mtproto) !==
JSON.stringify(originalConfig.system.mtproto) ||
JSON.stringify(config.system.tables) !==
JSON.stringify(originalConfig.system.tables) ||
JSON.stringify(config.queue.devices) !==
@ -216,6 +224,11 @@ export function SettingsPage() {
JSON.stringify(config.system.checker) !==
JSON.stringify(originalConfig.system.checker),
// MTProto
[TABS.MTPROTO]:
JSON.stringify(config.system.mtproto) !==
JSON.stringify(originalConfig.system.mtproto),
// API
[TABS.API]:
JSON.stringify(config.system.api) !==
@ -471,11 +484,6 @@ export function SettingsPage() {
<Socks5Settings config={config} onChange={handleChange} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
<Box sx={{ width: "100%" }}>
<MTProtoSettings config={config} onChange={handleChange} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex" }}>
<Box sx={{ width: "100%" }}>
@ -508,6 +516,16 @@ export function SettingsPage() {
<CheckerSettings config={config} onChange={handleChange} />
</TabPanel>
<TabPanel value={validTab} index={TABS.MTPROTO}>
<Grid container spacing={spacing.lg} alignItems="stretch">
<Grid size={{ xs: 12 }} sx={{ display: "flex" }}>
<Box sx={{ width: "100%" }}>
<MTProtoSettings config={config} onChange={handleChange} />
</Box>
</Grid>
</Grid>
</TabPanel>
<TabPanel value={validTab} index={TABS.PAYLOADS}>
<CaptureSettings />
</TabPanel>

View file

@ -104,6 +104,8 @@
"geodatDesc": "Global geodata configuration",
"discovery": "Discovery",
"discoveryDesc": "DPI bypass domains testing",
"mtproto": "MTProto Proxy",
"mtprotoDesc": "Built-in Telegram proxy",
"api": "API",
"apiDesc": "API settings for various services",
"payloads": "Payloads",
@ -230,7 +232,9 @@
"MTProto": {
"title": "MTProto Proxy",
"description": "Built-in Telegram MTProto proxy with fake-TLS obfuscation",
"settings": "MTProto Settings",
"credit": "WebSocket transport idea inspired by",
"settings": "Proxy server",
"serverDesc": "A Telegram proxy that clients connect to with a secret (on this LAN or remotely, e.g. from a cellular network). Requires the toggle below. Not needed for the 'Telegram over WebSocket' routing mode.",
"enable": "Enable MTProto Proxy",
"enableDesc": "Telegram proxy that wraps traffic in fake TLS to bypass DPI",
"bindAddress": "Bind Address",
@ -240,6 +244,9 @@
"portHelp": "MTProto listen port (default: 3128)",
"fakeSNI": "Fake SNI Domain",
"fakeSNIHelp": "Domain to impersonate in TLS handshake (e.g. storage.googleapis.com)",
"cfWorkerDomain": "Cloudflare Worker domain",
"cfWorkerDomainHelp": "Free per-user WebSocket relay (workers.dev). Recommended when media, reactions, or stickers fail to load. Reaches all data centers and avoids the shared-domain rate limits. Comma-separate multiple workers. Click the icon for setup.",
"cfWorkerSetup": "How to set up a Cloudflare Worker",
"dcRelay": "DC Relay",
"dcRelayHelp": "External relay address (host:port) to reach Telegram DCs when blocked by IP",
"dcRelayHelpButton": "Show socat commands for relay setup",
@ -266,7 +273,6 @@
"refreshingDCs": "Fetching…",
"refreshDCsOk": "Loaded {{count}} DC entries from Telegram.",
"refreshDCsErr": "Refresh failed: {{error}}",
"restartNote": "Restart B4 after changing MTProto settings for changes to take effect.",
"shareLink": "Share connection link",
"shareNative": "Share",
"shareDialogTitle": "Share MTProto connection",
@ -276,8 +282,9 @@
"shareLinkHelp": "Open this link on a device with Telegram installed to add the proxy.",
"shareQrHelp": "Scan with the phone camera to open in Telegram.",
"shareOpen": "Open in Telegram",
"upstreamTitle": "Upstream Transport",
"upstreamDesc": "How b4 reaches Telegram data centers from this host. Use WebSocket if your network blocks direct TCP to 149.154.x.x.",
"upstreamTitle": "Telegram upstream (shared)",
"upstreamDesc": "How b4 reaches Telegram data centers. Used by the proxy server above and by the 'Telegram over WebSocket' routing mode in sets, so it applies even when the proxy server is off. Use WebSocket if your network blocks direct TCP to 149.154.x.x.",
"upstreamBridgeNote": "Transport mode applies to the proxy server only; the 'Telegram over WebSocket' routing mode always uses Auto (WebSocket-first with TCP fallback).",
"upstreamMode": "Transport mode",
"upstreamTcp": "Direct TCP",
"upstreamTcpDesc": "Fastest. Use when this host can reach 149.154.0.0/16 directly.",
@ -286,9 +293,13 @@
"upstreamWs": "WebSocket only",
"upstreamWsDesc": "Strict WebSocket transport via kws*.web.telegram.org. No TCP fallback.",
"wsCustomDomain": "Custom WebSocket domain",
"wsCustomDomainHelp": "Optional. Enter the base domain (e.g. your-domain.com) that proxies WebSocket traffic to Telegram. b4 prepends kws1., kws2., ... per DC automatically. Leave empty to use Telegram's native kws*.web.telegram.org.",
"wsCustomDomainHelp": "Optional extra domain (e.g. your-domain.com) that proxies WebSocket traffic to Telegram. b4 prepends kws1., kws2., ... per DC automatically. Independent of the CF proxy fallback below.",
"wsEndpointHost": "Telegram WS edge IP (advanced)",
"wsEndpointHostHelp": "Override the IP used when WS uses native kws*.web.telegram.org SNI. Default: 149.154.167.220. Leave empty for default. Does not affect the custom WebSocket domain path.",
"cfProxyEnabled": "CF proxy fallback",
"cfProxyEnabledHelp": "Use a rotating pool of Cloudflare-proxied domains as a fallback when Telegram's native edge can't reach a data center (notably DC 1, which is needed for media in foreign channels). The pool is refreshed once an hour from the URL below.",
"cfProxyURL": "CF proxy domain list URL",
"cfProxyURLHelp": "Source for the encoded CF-proxy domain list. Default is tg-ws-proxy's GitHub. Replace with your own if you host your own list.",
"testWs": "Test connection",
"testWsRunning": "Testing…",
"testWsHelp": "Probes DC 2 over the configured transport(s) and reports latency.",
@ -1201,6 +1212,12 @@
"modeHelper": "Send matched traffic out a network interface, or hand it off to an upstream SOCKS5 proxy (e.g. local Xray/sing-box)",
"modeInterface": "Network interface",
"modeProxy": "Upstream SOCKS5 proxy",
"modeMTProtoWS": "Telegram over WebSocket (built-in)",
"flowMTProtoWS": "Telegram WS edge",
"flowMTProtoWSCaption": "Matched Telegram traffic is bridged over Telegram's WebSocket edge by B4 - no in-app proxy and no VPS needed.",
"howItWorksMTProtoWS": "When a device connects to a Telegram datacenter, B4 transparently intercepts it and relays the session over Telegram's WebSocket edge (with Cloudflare fallback), so Telegram works without configuring a proxy inside the app. Pair this with the 'telegram' geoip target. Uses the shared 'Telegram upstream' settings (Settings -> MTProto), which apply even when the proxy server is off. Best-effort: only TCP MTProto is bridged (voice calls and unknown transports fall through directly).",
"infoMTProtoWS": "Routing intercepts matched Telegram traffic from the source interfaces above and bridges it over the WebSocket edge. Connections B4 cannot map to a datacenter fall open to a direct connection.",
"mtprotoWsNote": "This mode runs on its own - the MTProto proxy under Settings -> MTProto does not need to be enabled. Matched Telegram traffic is relayed over B4's built-in WebSocket endpoint, with a Cloudflare fallback.",
"outputInterface": "Output Interface",
"outputInterfaceHelper": "Select egress interface for this set",
"interfaceUnavailable": "Selected interface is currently unavailable",

View file

@ -104,6 +104,8 @@
"geodatDesc": "Глобальная конфигурация геоданных",
"discovery": "Дискавери",
"discoveryDesc": "Тестирование доменов для обхода DPI",
"mtproto": "MTProto-прокси",
"mtprotoDesc": "Встроенный Telegram-прокси",
"api": "API",
"apiDesc": "Настройки API для различных сервисов",
"payloads": "Пэйлоады",
@ -226,7 +228,9 @@
"MTProto": {
"title": "MTProto Прокси",
"description": "Встроенный Telegram MTProto прокси с fake-TLS обфускацией",
"settings": "Настройки MTProto",
"credit": "Идея WebSocket-транспорта вдохновлена проектом",
"settings": "Прокси-сервер",
"serverDesc": "Telegram-прокси, к которому клиенты подключаются по секрету (в этой локальной сети или удалённо, например из сотовой сети). Требует переключатель ниже. Не нужен для режима маршрутизации «Telegram через WebSocket».",
"enable": "Включить MTProto прокси",
"enableDesc": "Telegram прокси, оборачивающий трафик в fake TLS для обхода DPI",
"bindAddress": "Адрес привязки",
@ -236,6 +240,9 @@
"portHelp": "Порт MTProto (по умолчанию: 3128)",
"fakeSNI": "Домен для Fake SNI",
"fakeSNIHelp": "Домен для имитации в TLS (например storage.googleapis.com)",
"cfWorkerDomain": "Домен Cloudflare Worker",
"cfWorkerDomainHelp": "Бесплатный персональный WebSocket-релей (workers.dev). Рекомендуется, когда не загружаются медиа, реакции или стикеры. Достаёт все дата-центры и обходит лимиты общих доменов. Несколько воркеров указывайте через запятую. Нажмите на иконку для инструкции.",
"cfWorkerSetup": "Как настроить Cloudflare Worker",
"dcRelay": "DC Relay",
"dcRelayHelp": "Адрес внешнего релея (host:port) для доступа к Telegram DC при блокировке по IP",
"dcRelayHelpButton": "Показать команды socat для настройки relay",
@ -262,7 +269,6 @@
"refreshingDCs": "Загрузка…",
"refreshDCsOk": "Загружено {{count}} записей DC от Telegram.",
"refreshDCsErr": "Не удалось обновить: {{error}}",
"restartNote": "Перезапустите B4 после изменения настроек MTProto.",
"shareLink": "Поделиться ссылкой",
"shareNative": "Поделиться",
"shareDialogTitle": "Поделиться подключением MTProto",
@ -272,8 +278,9 @@
"shareLinkHelp": "Откройте эту ссылку на устройстве с установленным Telegram, чтобы добавить прокси.",
"shareQrHelp": "Отсканируйте камерой телефона, чтобы открыть в Telegram.",
"shareOpen": "Открыть в Telegram",
"upstreamTitle": "Транспорт к Telegram",
"upstreamDesc": "Как b4 достигает дата-центров Telegram с этого хоста. Используйте WebSocket, если ваша сеть блокирует прямой TCP к 149.154.x.x.",
"upstreamTitle": "Транспорт к Telegram (общий)",
"upstreamDesc": "Как b4 достигает дата-центров Telegram. Используется и прокси-сервером выше, и режимом маршрутизации «Telegram через WebSocket» в сетах, поэтому действует даже когда прокси-сервер выключен. Используйте WebSocket, если ваша сеть блокирует прямой TCP к 149.154.x.x.",
"upstreamBridgeNote": "Режим транспорта касается только прокси-сервера; режим маршрутизации «Telegram через WebSocket» всегда использует Auto (сначала WebSocket, затем TCP).",
"upstreamMode": "Режим транспорта",
"upstreamTcp": "Прямой TCP",
"upstreamTcpDesc": "Самый быстрый. Подходит, если этот хост может напрямую достучаться до 149.154.0.0/16.",
@ -282,9 +289,13 @@
"upstreamWs": "Только WebSocket",
"upstreamWsDesc": "Жёсткий WebSocket-транспорт через kws*.web.telegram.org. Без TCP-резерва.",
"wsCustomDomain": "Свой WebSocket-домен",
"wsCustomDomainHelp": "Опционально. Укажите базовый домен (например, your-domain.com), который проксирует WebSocket-трафик к Telegram. b4 сам подставит kws1., kws2., ... перед каждым DC. Пусто — использовать встроенный kws*.web.telegram.org.",
"wsCustomDomainHelp": "Опциональный дополнительный домен (например, your-domain.com), который проксирует WebSocket-трафик к Telegram. b4 сам подставит kws1., kws2., ... перед каждым DC. Не зависит от CF-резерва ниже.",
"wsEndpointHost": "IP edge-сервера Telegram (расширенное)",
"wsEndpointHostHelp": "IP, к которому подключаемся для встроенного kws*.web.telegram.org SNI. По умолчанию 149.154.167.220. Оставьте пустым для значения по умолчанию. Не влияет на путь через свой WebSocket-домен.",
"cfProxyEnabled": "CF-резерв",
"cfProxyEnabledHelp": "Использовать ротируемый пул доменов через Cloudflare как запасной путь, когда нативный edge Telegram не доходит до нужного DC (особенно DC 1, через который идёт медиа из иностранных каналов). Список обновляется раз в час по ссылке ниже.",
"cfProxyURL": "URL списка CF-доменов",
"cfProxyURLHelp": "Источник зашифрованного списка CF-доменов. По умолчанию — GitHub tg-ws-proxy. Замените, если используете свой список.",
"testWs": "Проверить соединение",
"testWsRunning": "Проверка…",
"testWsHelp": "Пробует достучаться до DC 2 настроенным транспортом (или транспортами) и измеряет задержку.",
@ -1198,6 +1209,12 @@
"modeHelper": "Отправлять совпавший трафик через сетевой интерфейс или передавать его в upstream SOCKS5 proxy (например, локальный Xray/sing-box)",
"modeInterface": "Сетевой интерфейс",
"modeProxy": "Upstream SOCKS5 proxy",
"modeMTProtoWS": "Telegram через WebSocket (встроенный)",
"flowMTProtoWS": "WS-узел Telegram",
"flowMTProtoWSCaption": "Совпавший трафик Telegram переправляется B4 через WebSocket-узел Telegram — без прокси в приложении и без VPS.",
"howItWorksMTProtoWS": "Когда устройство подключается к дата-центру Telegram, B4 прозрачно перехватывает соединение и ретранслирует сессию через WebSocket-узел Telegram (с запасным вариантом через Cloudflare), поэтому Telegram работает без настройки прокси в приложении. Используйте вместе с geoip-целью «telegram». Используются общие настройки «Транспорт к Telegram» (Настройки -> MTProto), которые действуют даже когда прокси-сервер выключен. Best-effort: переправляется только TCP MTProto (голосовые звонки и неизвестные транспорты идут напрямую).",
"infoMTProtoWS": "Маршрутизация перехватывает совпавший трафик Telegram с указанных выше source-интерфейсов и переправляет его через WebSocket-узел. Соединения, для которых B4 не может определить дата-центр, идут напрямую (fail-open).",
"mtprotoWsNote": "Этот режим работает самостоятельно - MTProto-прокси в разделе Настройки -> MTProto включать не требуется. Совпавший трафик Telegram переправляется через встроенный WebSocket-узел B4, с запасным вариантом через Cloudflare.",
"outputInterface": "Выходной интерфейс",
"outputInterfaceHelper": "Выберите выходной интерфейс для этого сета",
"interfaceUnavailable": "Выбранный интерфейс сейчас недоступен",

View file

@ -299,6 +299,9 @@ export interface MTProtoConfig {
upstream_mode: "tcp" | "ws" | "auto";
ws_custom_domain: string;
ws_endpoint_host: string;
cfproxy_enabled: boolean;
cfproxy_url: string;
cfworker_domain: string;
}
@ -377,7 +380,7 @@ export interface DNSConfig {
fragment_query: boolean;
}
export type RoutingMode = "interface" | "proxy";
export type RoutingMode = "interface" | "proxy" | "mtproto-ws";
export interface UpstreamProxyConfig {
host: string;

View file

@ -1,24 +1,51 @@
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
if (globalThis.isSecureContext && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to legacy path */
}
const active = document.activeElement as HTMLElement | null;
const ta = document.createElement("textarea");
ta.value = text;
ta.contentEditable = "true";
ta.readOnly = false;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.style.top = "0";
ta.style.left = "0";
ta.style.width = "1px";
ta.style.height = "1px";
ta.style.padding = "0";
ta.style.border = "none";
ta.style.outline = "none";
ta.style.boxShadow = "none";
ta.style.background = "transparent";
const host = active?.closest("[role='dialog']") ?? document.body;
host.appendChild(ta);
ta.focus();
ta.select();
const range = document.createRange();
range.selectNodeContents(ta);
const sel = globalThis.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
ta.setSelectionRange(0, text.length);
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
/* legacy copy failed too */
}
ta.remove();
active?.focus?.();
return ok;
}

View file

@ -117,6 +117,9 @@ func runB4(cmd *cobra.Command, args []string) error {
var cfgPtr atomic.Pointer[config.Config]
cfgPtr.Store(&cfg)
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
aiManager := ai.NewManager(cfg.System.AI, cfg.ConfigPath)
handler.SetAIManager(aiManager)
@ -125,6 +128,18 @@ func runB4(cmd *cobra.Command, args []string) error {
tproxyResolver := tproxy.NewLearnedIPResolver(nil)
tproxyMgr := tproxy.NewManager(tproxyResolver)
mtprotoBridge := mtproto.NewTransparentBridge(&cfg)
tproxyMgr.SetMTProtoBridge(mtprotoBridge)
handler.SetMTProtoBridge(mtprotoBridge)
go func() { _ = mtproto.RefreshDCs() }()
startCFRefresh := func(c *config.Config) {
if c.System.MTProto.CFProxyEnabled {
mtproto.StartCFProxyRefresh(appCtx, c.System.MTProto.CFProxyURL)
}
}
startCFRefresh(&cfg)
handler.SetMTProtoCFRefreshFunc(startCFRefresh)
handler.SetTablesRefreshFunc(func() error {
c := cfgPtr.Load()
if c.System.Tables.SkipSetup {
@ -268,6 +283,7 @@ func runB4(cmd *cobra.Command, args []string) error {
metrics.RecordEvent("error", fmt.Sprintf("Failed to start MTProto server: %v", err))
log.Errorf("MTProto server did not start: %v (b4 continues without it; fix in Settings or config)", err)
}
handler.SetMTProtoServer(mtprotoServer)
wd := watchdog.New(&cfgPtr, discoveryRT, func(c *config.Config) error {
if err := c.Validate(); err != nil {
@ -282,6 +298,9 @@ func runB4(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save config: %v", err)
}
cfgPtr.Store(c)
mtprotoServer.UpdateConfig(c)
mtprotoBridge.UpdateConfig(c)
startCFRefresh(c)
tproxyResolver.Set(pool.GetMatcher())
tproxyMgr.SyncConfig(c)
tables.RoutingSyncConfig(c)

384
src/mtproto/cfproxy.go Normal file
View file

@ -0,0 +1,384 @@
package mtproto
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/daniellavrushin/b4/log"
)
const (
cfProxySuffix = ".co.uk"
DefaultCFProxyURL = "https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt"
cfProxyRefreshInt = time.Hour
cfProxyMinValid = 3
cfProxyFetchMaxLen = 65536
// cfProxyDomainCooldown is how long a CF-proxy domain is skipped after it
// returns 429/503. Shared public domains get rate-limited in bursts; without
// this, every dial re-hammers all 11 and DC1/3/5 (which have no Telegram WS
// edge) stall entirely. Matches the observed recovery window in the field.
cfProxyDomainCooldown = 60 * time.Second
)
// defaultCFProxyEncoded mirrors tg-ws-proxy/proxy/config.py:_CFPROXY_ENC.
// Caesar-shifted by alpha-char-count, .com suffix swapped for .co.uk at decode.
var defaultCFProxyEncoded = []string{
"virkgj.com",
"vmmzovy.com",
"mkuosckvso.com",
"zaewayzmplad.com",
"twdmbzcm.com",
"awzwsldi.com",
"clngqrflngqin.com",
"tjacxbqtj.com",
"bxaxtxmrw.com",
"dmohrsgmohcrwb.com",
}
// decodeCFDomain reverses the Flowseal/tg-ws-proxy obfuscation.
// Lines that don't end in .com pass through unchanged.
func decodeCFDomain(s string) string {
if !strings.HasSuffix(s, ".com") {
return s
}
p := s[:len(s)-4]
n := 0
for _, c := range p {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
n++
}
}
var b strings.Builder
b.Grow(len(p) + len(cfProxySuffix))
for _, c := range p {
switch {
case c >= 'a' && c <= 'z':
shifted := ((int(c)-'a'-n)%26 + 26) % 26
b.WriteByte(byte(shifted) + 'a')
case c >= 'A' && c <= 'Z':
shifted := ((int(c)-'A'-n)%26 + 26) % 26
b.WriteByte(byte(shifted) + 'A')
default:
b.WriteRune(c)
}
}
b.WriteString(cfProxySuffix)
return b.String()
}
func defaultCFProxyDomains() []string {
out := make([]string, 0, len(defaultCFProxyEncoded))
for _, e := range defaultCFProxyEncoded {
out = append(out, decodeCFDomain(e))
}
return out
}
// isValidCFDomain mirrors tg-ws-proxy/proxy/config.py:_is_valid_domain.
func isValidCFDomain(domain string) bool {
if domain == "" || len(domain) > 253 {
return false
}
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return false
}
for _, label := range labels {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, c := range label {
ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-'
if !ok {
return false
}
}
}
tld := labels[len(labels)-1]
if len(tld) < 2 {
return false
}
hasAlpha := false
for _, c := range tld {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
hasAlpha = true
break
}
}
return hasAlpha
}
// normalizeCFDomains lowercases, trims, validates, and dedupes (preserves order).
func normalizeCFDomains(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, d := range in {
d = strings.ToLower(strings.TrimSpace(d))
if !isValidCFDomain(d) || seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
return out
}
// cfBalancer is the Go equivalent of tg-ws-proxy/proxy/balancer.py:_Balancer.
// Per-DC sticky domain selection over a rotating pool; the active domain is
// tried first, the remaining pool is shuffled.
type cfBalancer struct {
mu sync.Mutex
domains []string
perDC map[int]string
cooldown map[string]time.Time
}
func newCFBalancer() *cfBalancer {
b := &cfBalancer{
domains: defaultCFProxyDomains(),
perDC: map[int]string{},
cooldown: map[string]time.Time{},
}
b.seedPerDC()
return b
}
// seedPerDC picks a random domain from the current pool for each known DC.
// Caller must hold mu.
func (b *cfBalancer) seedPerDC() {
if len(b.domains) == 0 {
b.perDC = map[int]string{}
return
}
dcs := []int{1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 203}
b.perDC = make(map[int]string, len(dcs))
for _, dc := range dcs {
b.perDC[dc] = b.domains[cfproxyRand(len(b.domains))]
}
}
func (b *cfBalancer) updateDomainsList(list []string) {
b.mu.Lock()
defer b.mu.Unlock()
if len(list) == 0 || sameStringSet(b.domains, list) {
return
}
b.domains = append([]string(nil), list...)
b.seedPerDC()
}
// domainsForDC returns the trial order for `dc`: current pinned first, then a
// shuffle of the rest. Domains in 429/503 cooldown are dropped, unless that
// would leave nothing (DC1/3/5 have no other WS path), in which case cooldown
// is ignored so we still try. Empty only if the pool itself is empty.
func (b *cfBalancer) domainsForDC(dc int) []string {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.domains) == 0 {
return nil
}
now := time.Now()
cooled := func(d string) bool {
t, ok := b.cooldown[d]
return ok && now.Before(t)
}
current := b.perDC[dc]
others := make([]string, 0, len(b.domains))
for _, d := range b.domains {
if d != current {
others = append(others, d)
}
}
cfproxyShuffle(others)
out := make([]string, 0, len(b.domains))
if current != "" && !cooled(current) {
out = append(out, current)
}
for _, d := range others {
if !cooled(d) {
out = append(out, d)
}
}
if len(out) > 0 {
return out
}
// everything is cooled down: fall back to trying all (still better than no path)
if current != "" {
out = append(out, current)
}
return append(out, others...)
}
// penalize puts a CF-proxy domain into cooldown after a 429/503 so subsequent
// dials skip it until it recovers.
func (b *cfBalancer) penalize(domain string, d time.Duration) {
if domain == "" {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.cooldown[domain] = time.Now().Add(d)
}
// pin records that `domain` worked for `dc`. Returns true if the pin changed.
func (b *cfBalancer) pin(dc int, domain string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.perDC[dc] == domain {
return false
}
b.perDC[dc] = domain
return true
}
func (b *cfBalancer) size() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.domains)
}
// refreshFromURL fetches the encoded domain list from the given URL,
// decodes + validates, and replaces the pool if the new list is sane.
func (b *cfBalancer) refreshFromURL(url string) error {
if url == "" {
url = DefaultCFProxyURL
}
// match tg-ws-proxy: random querystring suffix to defeat caches
req, err := http.NewRequest("GET", url+"?cb="+cfproxyCacheBust(), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "b4-mtproto")
cli := &http.Client{Timeout: 10 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cfProxyFetchMaxLen))
if err != nil {
return err
}
var decoded []string
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
decoded = append(decoded, decodeCFDomain(line))
}
pool := normalizeCFDomains(decoded)
if len(pool) < cfProxyMinValid {
return fmt.Errorf("only %d valid domains, keeping current pool", len(pool))
}
b.updateDomainsList(pool)
return nil
}
// cfBalancerInst is the package-level singleton. Initialized with the bundled
// defaults so b4 has a working CF pool from process start even before the
// first GitHub refresh succeeds.
var cfBalancerInst = newCFBalancer()
func sameStringSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
counts := make(map[string]int, len(a))
for _, s := range a {
counts[s]++
}
for _, s := range b {
counts[s]--
}
for _, v := range counts {
if v != 0 {
return false
}
}
return true
}
// cfproxyRand returns an unbiased-ish int in [0,n) using crypto/rand.
// Non-crypto usage; modulo bias on a 64-bit value is negligible for our pool sizes.
func cfproxyRand(n int) int {
if n <= 0 {
return 0
}
var buf [8]byte
_, _ = rand.Read(buf[:])
v := uint64(buf[0]) | uint64(buf[1])<<8 | uint64(buf[2])<<16 | uint64(buf[3])<<24 |
uint64(buf[4])<<32 | uint64(buf[5])<<40 | uint64(buf[6])<<48 | uint64(buf[7])<<56
return int(v % uint64(n))
}
func cfproxyShuffle(s []string) {
for i := len(s) - 1; i > 0; i-- {
j := cfproxyRand(i + 1)
s[i], s[j] = s[j], s[i]
}
}
func cfproxyCacheBust() string {
var b [4]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
var (
cfRefreshOnce sync.Once
cfRefreshURL atomic.Pointer[string]
)
func StartCFProxyRefresh(ctx interface{ Done() <-chan struct{} }, url string) {
cfRefreshURL.Store(&url)
cfRefreshOnce.Do(func() {
go runCFProxyRefreshLoop(ctx)
})
}
func runCFProxyRefreshLoop(ctx interface{ Done() <-chan struct{} }) {
currentURL := func() string {
if p := cfRefreshURL.Load(); p != nil {
return *p
}
return ""
}
if err := cfBalancerInst.refreshFromURL(currentURL()); err != nil {
log.Warnf("CF proxy refresh failed at startup: %v", err)
} else {
log.Infof("CF proxy pool refreshed (%d domains)", cfBalancerInst.size())
}
ticker := time.NewTicker(cfProxyRefreshInt)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := cfBalancerInst.refreshFromURL(currentURL()); err != nil {
log.Debugf("CF proxy refresh failed: %v", err)
} else {
log.Debugf("CF proxy pool refreshed (%d domains)", cfBalancerInst.size())
}
}
}
}

View file

@ -0,0 +1,57 @@
package mtproto
import (
"testing"
"time"
)
func TestBalancer_PenalizeCooldownSkipsDomain(t *testing.T) {
b := newCFBalancer()
all := b.domainsForDC(2)
if len(all) < 2 {
t.Fatalf("need >=2 default domains, got %d", len(all))
}
victim := all[0]
b.penalize(victim, cfProxyDomainCooldown)
got := b.domainsForDC(2)
for _, d := range got {
if d == victim {
t.Fatalf("penalized domain %q should be skipped, got %v", victim, got)
}
}
if len(got) != len(all)-1 {
t.Fatalf("expected %d domains after cooldown, got %d", len(all)-1, len(got))
}
}
func TestBalancer_AllCooledDownFallsBackToAll(t *testing.T) {
b := newCFBalancer()
all := b.domainsForDC(2)
for _, d := range all {
b.penalize(d, cfProxyDomainCooldown)
}
got := b.domainsForDC(2)
if len(got) != len(all) {
t.Fatalf("when all cooled, expect fallback to all %d, got %d", len(all), len(got))
}
}
func TestBalancer_CooldownExpires(t *testing.T) {
b := newCFBalancer()
all := b.domainsForDC(2)
victim := all[0]
b.penalize(victim, -time.Second) // already expired
got := b.domainsForDC(2)
found := false
for _, d := range got {
if d == victim {
found = true
break
}
}
if !found {
t.Fatalf("expired cooldown should restore %q, got %v", victim, got)
}
}

104
src/mtproto/cfproxy_test.go Normal file
View file

@ -0,0 +1,104 @@
package mtproto
import (
"testing"
)
func TestDecodeCFDomain_MatchesPythonReference(t *testing.T) {
cases := map[string]string{
// known good from user log: mkuosckvso.com decodes to cakeisalie.co.uk
"mkuosckvso.com": "cakeisalie.co.uk",
}
for in, want := range cases {
got := decodeCFDomain(in)
if got != want {
t.Errorf("decodeCFDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeCFDomain_PassesThroughNonDotCom(t *testing.T) {
if got := decodeCFDomain("already.co.uk"); got != "already.co.uk" {
t.Errorf("expected passthrough for non-.com, got %q", got)
}
}
func TestDefaultCFProxyDomains_AllValid(t *testing.T) {
pool := defaultCFProxyDomains()
if len(pool) != len(defaultCFProxyEncoded) {
t.Fatalf("default pool size mismatch: %d vs %d", len(pool), len(defaultCFProxyEncoded))
}
for _, d := range pool {
if !isValidCFDomain(d) {
t.Errorf("default decoded domain failed validation: %q", d)
}
}
}
func TestIsValidCFDomain(t *testing.T) {
good := []string{"example.com", "kws1.cakeisalie.co.uk", "a.b.co"}
bad := []string{"", ".com", "example.", "a..b", "-bad.com", "bad-.com", "x.1", "x." + string(rune('a'-1))}
for _, d := range good {
if !isValidCFDomain(d) {
t.Errorf("expected valid: %q", d)
}
}
for _, d := range bad {
if isValidCFDomain(d) {
t.Errorf("expected invalid: %q", d)
}
}
}
func TestBalancer_PinAndRotation(t *testing.T) {
b := newCFBalancer()
if b.size() != len(defaultCFProxyEncoded) {
t.Fatalf("expected default pool size %d, got %d", len(defaultCFProxyEncoded), b.size())
}
domains := b.domainsForDC(2)
if len(domains) != b.size() {
t.Fatalf("expected %d domains for DC2, got %d", b.size(), len(domains))
}
// pin a domain different from the randomly-seeded current one and verify
// it's listed first. Picking != current avoids a flake when seedPerDC
// happened to seed our target as DC2's active domain already.
target := domains[0]
if target == b.perDC[2] {
target = domains[len(domains)-1]
}
if !b.pin(2, target) {
t.Fatal("first pin should report change=true")
}
if b.pin(2, target) {
t.Fatal("idempotent pin should report change=false")
}
got := b.domainsForDC(2)
if got[0] != target {
t.Errorf("expected pinned %q first, got %q", target, got[0])
}
}
func TestBalancer_UpdateDomainsList_ReplacesPool(t *testing.T) {
b := newCFBalancer()
newList := []string{"a.co.uk", "b.co.uk", "c.co.uk"}
b.updateDomainsList(newList)
if b.size() != 3 {
t.Errorf("expected size 3 after update, got %d", b.size())
}
// unchanged update is a no-op
b.updateDomainsList([]string{"c.co.uk", "b.co.uk", "a.co.uk"}) // same set
if b.size() != 3 {
t.Errorf("set-equivalent update should not change pool, got size %d", b.size())
}
}
func TestNormalizeCFDomains_LowercaseTrimDedupe(t *testing.T) {
in := []string{"A.com", " b.co.uk ", "a.com", "invalid..", "c.com"}
out := normalizeCFDomains(in)
if len(out) != 3 {
t.Fatalf("expected 3 valid domains, got %v", out)
}
if out[0] != "a.com" {
t.Errorf("expected lowercase first, got %q", out[0])
}
}

View file

@ -31,6 +31,58 @@ var dcAddressesV6 = map[int]string{
5: "[2001:b28:f23f:f005::a]:443",
}
type dcNet struct {
net *net.IPNet
dc int
}
func mustParseDCNets(pairs [][2]interface{}) []dcNet {
out := make([]dcNet, 0, len(pairs))
for _, p := range pairs {
cidr, _ := p[0].(string)
dc, _ := p[1].(int)
_, n, err := net.ParseCIDR(cidr)
if err != nil || n == nil {
continue
}
out = append(out, dcNet{net: n, dc: dc})
}
return out
}
var dcRangesV4 = mustParseDCNets([][2]interface{}{
{"149.154.167.0/24", 2},
{"149.154.161.0/24", 2},
{"149.154.165.0/24", 4},
{"149.154.166.0/24", 4},
{"91.108.4.0/22", 4},
})
var dcRangesV6 = mustParseDCNets([][2]interface{}{
{"2001:67c:4e8:f002::/64", 2},
{"2001:67c:4e8:f004::/64", 4},
})
func dcForIPRange(ip net.IP) (int, bool) {
if ip == nil {
return 0, false
}
if ip.To4() != nil {
for _, e := range dcRangesV4 {
if e.net.Contains(ip) {
return e.dc, true
}
}
return 0, false
}
for _, e := range dcRangesV6 {
if e.net.Contains(ip) {
return e.dc, true
}
}
return 0, false
}
var (
dcRuntimeMu sync.RWMutex
dcRuntime = map[int][]string{}
@ -63,6 +115,40 @@ func DCSnapshotAll() map[int][]string {
return out
}
func dcForIP(ip net.IP) (int, bool) {
if ip == nil {
return 0, false
}
target := ip.String()
dcRuntimeMu.RLock()
for dc, addrs := range dcRuntime {
for _, a := range addrs {
host, _, err := net.SplitHostPort(a)
if err != nil {
host = a
}
if host == target {
dcRuntimeMu.RUnlock()
return dc, true
}
}
}
dcRuntimeMu.RUnlock()
for dc, a := range dcAddressesV4 {
if host, _, err := net.SplitHostPort(a); err == nil && host == target {
return dc, true
}
}
for dc, a := range dcAddressesV6 {
if host, _, err := net.SplitHostPort(a); err == nil && host == target {
return dc, true
}
}
return 0, false
}
func RefreshDCs() error {
cli := &http.Client{Timeout: 3 * time.Second}
var body []byte

46
src/mtproto/dc_test.go Normal file
View file

@ -0,0 +1,46 @@
package mtproto
import (
"net"
"testing"
)
func TestDCForIPRange(t *testing.T) {
cases := []struct {
ip string
want int
ok bool
}{
{"149.154.167.50", 2, true},
{"149.154.167.222", 2, true},
{"149.154.161.144", 2, true},
{"149.154.166.121", 4, true},
{"149.154.165.109", 4, true},
{"91.108.4.140", 4, true},
{"2001:67c:4e8:f002::a", 2, true},
{"2001:67c:4e8:f004::a", 4, true},
{"149.154.162.123", 0, false},
{"149.154.175.50", 0, false},
{"91.105.192.100", 0, false},
{"8.8.8.8", 0, false},
}
for _, c := range cases {
got, ok := dcForIPRange(net.ParseIP(c.ip))
if ok != c.ok || (ok && got != c.want) {
t.Errorf("dcForIPRange(%s) = (%d, %v), want (%d, %v)", c.ip, got, ok, c.want, c.ok)
}
}
}
func TestDCForIPRangeOnlyWSServedDCs(t *testing.T) {
for _, e := range dcRangesV4 {
if !wsEdgeServesDC(e.dc) {
t.Errorf("dcRangesV4 entry %s maps to DC%d which is not WS-served; range resolution should only assert WS-served DCs", e.net, e.dc)
}
}
for _, e := range dcRangesV6 {
if !wsEdgeServesDC(e.dc) {
t.Errorf("dcRangesV6 entry %s maps to DC%d which is not WS-served", e.net, e.dc)
}
}
}

View file

@ -23,7 +23,6 @@ const (
handshakeServerHello = 0x02
maxTLSRecordPayload = 16379
timestampTolerance = 120
secondDuration = time.Second
)
type FakeTLSConn struct {
@ -249,7 +248,6 @@ func buildServerHello(secret *Secret, clientRandom, sessionID []byte) []byte {
rand.Read(x25519Key)
var extensions bytes.Buffer
extensions.Write([]byte{0x00, 0x2b, 0x00, 0x02, 0x03, 0x04})
keyShareData := make([]byte, 0, 36)
keyShareData = append(keyShareData, 0x00, 0x1d, 0x00, 0x20)
keyShareData = append(keyShareData, x25519Key...)
@ -257,6 +255,7 @@ func buildServerHello(secret *Secret, clientRandom, sessionID []byte) []byte {
extLen := len(keyShareData)
extensions.Write([]byte{byte(extLen >> 8), byte(extLen)})
extensions.Write(keyShareData)
extensions.Write([]byte{0x00, 0x2b, 0x00, 0x02, 0x03, 0x04})
extBytes := extensions.Bytes()
shBody.Write([]byte{byte(len(extBytes) >> 8), byte(len(extBytes))})

View file

@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
@ -29,6 +30,41 @@ const (
tcpDialTimeout = 8 * time.Second
)
var wsEdgeServedDCs = map[int]bool{2: true, 4: true}
func wsEdgeServesDC(absDC int) bool {
return wsEdgeServedDCs[absDC]
}
// workerDomains parses the comma-separated CF Worker domain list from config.
func workerDomains(cfg *config.MTProtoConfig) []string {
raw := strings.TrimSpace(cfg.CFWorkerDomain)
if raw == "" {
return nil
}
var out []string
for _, d := range strings.Split(raw, ",") {
if d = strings.TrimSpace(d); d != "" {
out = append(out, d)
}
}
return out
}
// workerDstIP returns the canonical public DC IP the worker should TCP-connect to
// (the worker's ?dst= target). Mirrors tg-ws-proxy DC_DEFAULT_IPS.
func workerDstIP(absDC int) string {
addr, ok := dcAddressesV4[absDC]
if !ok {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
type ObfuscatedConn struct {
net.Conn
reader cipher.Stream
@ -56,15 +92,31 @@ type ClientHandshakeResult struct {
}
func AcceptObfuscated(conn net.Conn, secret *Secret) (*ClientHandshakeResult, error) {
return acceptObfuscatedFrame(conn, func(raw []byte) []byte {
return deriveKey(raw, secret.Key[:])
})
}
func acceptObfuscatedFrame(conn net.Conn, derive func(raw []byte) []byte) (*ClientHandshakeResult, error) {
frame := make([]byte, obfuscatedFrameLen)
if _, err := io.ReadFull(conn, frame); err != nil {
return nil, fmt.Errorf("read handshake: %w", err)
}
return decodeObfuscatedFrame(frame, conn, derive)
}
decKey := deriveKey(frame[8:40], secret.Key[:])
func decodeObfuscatedDirect(frame []byte, conn net.Conn) (*ClientHandshakeResult, error) {
return decodeObfuscatedFrame(frame, conn, func(raw []byte) []byte {
out := make([]byte, len(raw))
copy(out, raw)
return out
})
}
func decodeObfuscatedFrame(frame []byte, conn net.Conn, derive func(raw []byte) []byte) (*ClientHandshakeResult, error) {
decIV := make([]byte, 16)
copy(decIV, frame[40:56])
decStream, err := newAESCTR(decKey, decIV)
decStream, err := newAESCTR(derive(frame[8:40]), decIV)
if err != nil {
return nil, fmt.Errorf("init decrypt: %w", err)
}
@ -73,10 +125,9 @@ func AcceptObfuscated(conn net.Conn, secret *Secret) (*ClientHandshakeResult, er
for i := 0; i < 48; i++ {
reversed[i] = frame[55-i]
}
encKey := deriveKey(reversed[:32], secret.Key[:])
encIV := make([]byte, 16)
copy(encIV, reversed[32:48])
encStream, err := newAESCTR(encKey, encIV)
encStream, err := newAESCTR(derive(reversed[:32]), encIV)
if err != nil {
return nil, fmt.Errorf("init encrypt: %w", err)
}
@ -117,11 +168,18 @@ type transportPlan struct {
addr string
sni string
dialHost string
dc int
cfBase string // CF-proxy base domain (without "kwsN."); set => pin in balancer on success
wsPath string // WS request path; "" defaults to /apiws. CF Worker uses /apiws?dst=&dc=
isWorker bool // CF Worker plan: dedicated per-user relay, reaches any DC via ?dst=
}
func (p transportPlan) describe() string {
switch p.kind {
case transportWS:
if p.isWorker {
return "wsworker://" + p.sni
}
if p.dialHost != "" && p.dialHost != p.sni {
return fmt.Sprintf("ws://%s@%s", p.sni, p.dialHost)
}
@ -153,6 +211,9 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
return
}
for _, a := range addrs {
if tcpAddrInCooldown(a) {
continue
}
plans = append(plans, transportPlan{kind: transportTCP, addr: a})
}
}
@ -161,30 +222,57 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
appendTCP()
}
if mode == "ws" || mode == "auto" {
if (mode == "ws" || mode == "auto") && !wsIsBlacklisted(dc) {
edgeIP := strings.TrimSpace(cfg.WSEndpointHost)
if edgeIP == "" {
edgeIP = telegramWSEdgeIP
}
wsDC := absDC
if absDC == 203 {
wsDC = 2
}
if wsDC >= 1 && wsDC <= 5 {
primary := transportPlan{kind: transportWS, sni: fmt.Sprintf("kws%d.web.telegram.org", wsDC), dialHost: edgeIP}
media := transportPlan{kind: transportWS, sni: fmt.Sprintf("kws%d-1.web.telegram.org", wsDC), dialHost: edgeIP}
if wsEdgeServesDC(absDC) {
primary := transportPlan{kind: transportWS, dc: dc, sni: fmt.Sprintf("kws%d.web.telegram.org", absDC), dialHost: edgeIP}
media := transportPlan{kind: transportWS, dc: dc, sni: fmt.Sprintf("kws%d-1.web.telegram.org", absDC), dialHost: edgeIP}
if dc < 0 {
plans = append(plans, media, primary)
} else {
plans = append(plans, primary, media)
}
}
// CF Worker (free per-user workers.dev relay). Tried after TG's own edge so
// the fast native path wins for DC2/4, but before the shared CF pool so DCs
// without a native edge (1/3/5) and throttled cases use the dedicated worker
// instead of the rate-limited shared domains. The worker reaches any DC via
// ?dst=<canonical DC IP>&dc=<n> (matches tg-ws-proxy CfWorker mode).
if dst := workerDstIP(absDC); dst != "" {
for _, wd := range workerDomains(cfg) {
plans = append(plans, transportPlan{
kind: transportWS,
dc: dc,
sni: wd,
dialHost: wd,
wsPath: fmt.Sprintf("/apiws?dst=%s&dc=%d", dst, absDC),
isWorker: true,
})
}
}
if d := strings.TrimSpace(cfg.WSCustomDomain); d != "" {
plans = append(plans, transportPlan{
kind: transportWS,
sni: fmt.Sprintf("kws%d.%s", absDC, d),
kind: transportWS,
dc: dc,
sni: fmt.Sprintf("kws%d.%s", absDC, d),
cfBase: d,
})
}
// CF-proxy fallback pool (matches tg-ws-proxy). Tried after TG's own edge so
// the fast path wins when it works; CF rescues DCs the network blocks (esp. DC 1).
if cfg.CFProxyEnabled {
for _, base := range cfBalancerInst.domainsForDC(dc) {
plans = append(plans, transportPlan{
kind: transportWS,
dc: dc,
sni: fmt.Sprintf("kws%d.%s", absDC, base),
cfBase: base,
})
}
}
}
if mode == "auto" && !relayFirst {
@ -202,35 +290,139 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
}
func DialObfuscatedDC(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc int, protoTag uint32) (*ObfuscatedConn, string, error) {
return DialObfuscatedDCWithPool(cfg, queueCfg, dc, protoTag, nil)
}
func DialObfuscatedDCWithPool(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc int, protoTag uint32, pool *wsPool) (*ObfuscatedConn, string, error) {
if pool != nil && !wsIsBlacklisted(dc) {
if raw := pool.get(dc); raw != nil {
obf, err := completeObfuscation(raw, dc, protoTag)
if err == nil && raw.liveNow() {
log.Infof("MTProto DC %d connected via ws-pool", dc)
wsRecordSuccess(dc)
return obf, "ws-pool", nil
}
if err != nil {
log.Debugf("MTProto DC %d pool conn obf init failed: %v", dc, err)
} else {
log.Debugf("MTProto DC %d pool conn died before relay; re-dialing fresh", dc)
}
_ = raw.Close()
}
}
plans, err := planTransports(cfg, queueCfg, dc)
if err != nil {
return nil, "", err
}
var lastErr error
wsTimeout := wsDialTimeout
if wsCooldownActive(dc) {
wsTimeout = wsDialTimeoutCooldown
}
var attempts []string
wsTried := 0
wsRedirects := 0
for _, p := range plans {
log.Debugf("MTProto DC %d dialing %s", dc, p.describe())
start := time.Now()
conn, err := dialOne(p, queueCfg.Mark)
if err != nil {
lastErr = err
log.Debugf("MTProto DC %d %s failed after %dms: %v", dc, p.describe(), time.Since(start).Milliseconds(), err)
var conn net.Conn
var derr error
if p.kind == transportWS {
conn, derr = dialOneWS(p, queueCfg.Mark, wsTimeout)
} else {
conn, derr = dialOne(p, queueCfg.Mark)
}
if derr != nil {
attempts = append(attempts, fmt.Sprintf("%s: %s", p.describe(), shortErr(derr)))
if p.kind == transportWS {
wsTried++
if isWSRedirect(derr) {
wsRedirects++
}
if p.cfBase != "" && wsRateLimited(derr) {
cfBalancerInst.penalize(p.cfBase, cfProxyDomainCooldown)
}
} else if isDialTimeout(derr) {
tcpRecordFailure(p.addr)
}
log.Debugf("MTProto DC %d %s failed after %dms: %v", dc, p.describe(), time.Since(start).Milliseconds(), derr)
continue
}
obfConn, err := completeObfuscation(conn, dc, protoTag)
if err != nil {
lastErr = err
obfConn, oerr := completeObfuscation(conn, dc, protoTag)
if oerr != nil {
attempts = append(attempts, fmt.Sprintf("%s: %s", p.describe(), shortErr(oerr)))
conn.Close()
log.Debugf("MTProto DC %d obf init failed on %s: %v", dc, p.describe(), err)
log.Debugf("MTProto DC %d obf init failed on %s: %v", dc, p.describe(), oerr)
continue
}
if p.kind == transportWS {
wsRecordSuccess(dc)
// pin successful CF-proxy domain for this DC so subsequent connections
// try it first (mirrors tg-ws-proxy/proxy/balancer.py:update_domain_for_dc)
if p.cfBase != "" {
if cfBalancerInst.pin(dc, p.cfBase) {
log.Infof("MTProto DC %d switched active CF domain to %s", dc, p.cfBase)
}
}
} else {
tcpRecordSuccess(p.addr)
}
log.Infof("MTProto DC %d connected via %s in %dms", dc, p.describe(), time.Since(start).Milliseconds())
return obfConn, p.describe(), nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no transport succeeded")
if wsTried > 0 {
wsRecordFailure(dc, wsRedirects == wsTried)
}
return nil, "", lastErr
if len(attempts) == 0 {
return nil, "", fmt.Errorf("no transport available (all in cooldown or blacklisted)")
}
return nil, "", fmt.Errorf("all transports failed: %s", strings.Join(attempts, "; "))
}
func isDialTimeout(err error) bool {
if err == nil {
return false
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true
}
return strings.Contains(err.Error(), "i/o timeout")
}
func shortErr(err error) string {
s := err.Error()
for _, p := range []string{"tcp dial ", "tls handshake ", "ws read response: ", "ws write upgrade: ", "ws handshake "} {
s = strings.TrimPrefix(s, p)
}
return s
}
func isWSRedirect(err error) bool {
var he *wsHandshakeError
if !errors.As(err, &he) {
return false
}
return he.isRedirect()
}
func wsRateLimited(err error) bool {
var he *wsHandshakeError
if !errors.As(err, &he) {
return false
}
return he.statusCode == 429 || he.statusCode == 503
}
func dialOneWS(p transportPlan, mark uint, timeout time.Duration) (net.Conn, error) {
host := p.dialHost
if host == "" {
host = p.sni
}
return dialWS(host, p.sni, p.wsPath, timeout, mark)
}
type TransportProbeResult struct {
@ -300,7 +492,7 @@ func dialOne(p transportPlan, mark uint) (net.Conn, error) {
if host == "" {
host = p.sni
}
return dialWS(host, p.sni, wsDialTimeout, mark)
return dialWS(host, p.sni, p.wsPath, wsDialTimeout, mark)
default:
dialer := net.Dialer{Timeout: tcpDialTimeout}
if mark > 0 {
@ -351,7 +543,11 @@ func completeObfuscation(conn net.Conn, dc int, protoTag uint32) (*ObfuscatedCon
encrypted := make([]byte, obfuscatedFrameLen)
copy(encrypted, frame)
encStream.XORKeyStream(encrypted, encrypted)
copy(encrypted[8:56], frame[8:56])
// Restore bytes 0:56 to plaintext - only bytes 56:64 (proto_tag||dc||rnd) are
// sent encrypted. tg-ws-proxy keeps the SKIP region (0:8) as plaintext too;
// b4 was only restoring 8:56, leaving 0:7 as ciphertext on the wire. Some TG
// endpoints inspect the SKIP bytes before decryption.
copy(encrypted[0:56], frame[0:56])
if _, err := conn.Write(encrypted); err != nil {
return nil, fmt.Errorf("send handshake: %w", err)
@ -364,6 +560,37 @@ func completeObfuscation(conn net.Conn, dc int, protoTag uint32) (*ObfuscatedCon
}, nil
}
// reservedFirst4Words are the first-4-byte little-endian values an obfuscated
// frame must never start with: they collide with TLS/HTTP and the obfuscation
// transport tags, so TG treats a connection beginning with one of them as a
// different protocol. generateFrame avoids producing them; reservedFirst4
// (transparent bridge) uses them to detect a non-obfuscated transport. Keep the
// two in sync via this single list.
var reservedFirst4Words = []uint32{
0x44414548, // "HEAD"
0x54534f50, // "POST"
0x20544547, // "GET "
0x4954504f, // "OPTI"
0x02010316, // TLS record header
0xdddddddd, // padded intermediate tag
0xeeeeeeee, // intermediate tag
}
// isReservedFirst4 reports whether the first 4 bytes are a reserved value
// (0xef abridged-tag first byte, or any reservedFirst4Words value).
func isReservedFirst4(b []byte) bool {
if b[0] == 0xef {
return true
}
first4 := binary.LittleEndian.Uint32(b[:4])
for _, w := range reservedFirst4Words {
if first4 == w {
return true
}
}
return false
}
func generateFrame(dc int, protoTag uint32) []byte {
frame := make([]byte, obfuscatedFrameLen)
for {
@ -371,14 +598,7 @@ func generateFrame(dc int, protoTag uint32) []byte {
continue
}
if frame[0] == 0xef {
continue
}
first4 := binary.LittleEndian.Uint32(frame[0:4])
if first4 == 0x44414548 || first4 == 0x54534f50 ||
first4 == 0x20544547 || first4 == 0x4954504f ||
first4 == 0x02010316 || first4 == 0xdddddddd ||
first4 == 0xeeeeeeee {
if isReservedFirst4(frame[0:4]) {
continue
}
if binary.LittleEndian.Uint32(frame[4:8]) == 0 {

View file

@ -44,27 +44,30 @@ func TestPlanTransports_WSOnly_DC2(t *testing.T) {
func TestPlanTransports_MediaDC_ReversesOrdering(t *testing.T) {
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, -3)
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, -4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := wsSNIs(plans)
want := []string{"kws3-1.web.telegram.org", "kws3.web.telegram.org"}
want := []string{"kws4-1.web.telegram.org", "kws4.web.telegram.org"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("media DC -3 order: got %v want %v", got, want)
t.Fatalf("media DC -4 order: got %v want %v", got, want)
}
}
func TestPlanTransports_DC203_RemapsToKws2(t *testing.T) {
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
func TestPlanTransports_DC203_NoDirectEdge(t *testing.T) {
cfg := &config.MTProtoConfig{UpstreamMode: "auto"}
plans, err := planTransports(cfg, config.QueueConfig{IPv4Enabled: true}, 203)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := wsSNIs(plans)
want := []string{"kws2.web.telegram.org", "kws2-1.web.telegram.org"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("DC 203 should remap to kws2: got %v want %v", got, want)
for _, s := range wsSNIs(plans) {
if strings.HasSuffix(s, ".web.telegram.org") {
t.Fatalf("DC 203 must not use the TG WS edge (front cannot serve CDN), got %q", s)
}
}
if !hasTCP(plans) {
t.Fatalf("DC 203 should fall back to TCP, got %+v", plans)
}
}
@ -247,3 +250,98 @@ func TestPlanTransports_DCRelay_IgnoredInWSMode(t *testing.T) {
t.Fatalf("expected WS plans for DC 2 in ws mode, got none")
}
}
func TestPlanTransports_WorkerForDC2BeforeCFPool(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "ws",
CFWorkerDomain: "my-worker-123.user.workers.dev",
CFProxyEnabled: true,
}
plans, err := planTransports(cfg, config.QueueConfig{}, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var workerIdx, cfIdx, edgeIdx = -1, -1, -1
for i, p := range plans {
switch {
case p.isWorker && workerIdx == -1:
workerIdx = i
case p.cfBase != "" && cfIdx == -1:
cfIdx = i
case !p.isWorker && p.cfBase == "" && edgeIdx == -1:
edgeIdx = i
}
}
if workerIdx == -1 {
t.Fatal("expected a worker plan for DC2")
}
if edgeIdx == -1 || workerIdx < edgeIdx {
t.Errorf("worker (%d) should come after native edge (%d)", workerIdx, edgeIdx)
}
if cfIdx != -1 && workerIdx > cfIdx {
t.Errorf("worker (%d) should come before shared CF pool (%d)", workerIdx, cfIdx)
}
wp := plans[workerIdx]
if wp.wsPath != "/apiws?dst=149.154.167.51&dc=2" {
t.Errorf("unexpected worker path %q", wp.wsPath)
}
if wp.sni != "my-worker-123.user.workers.dev" || wp.dialHost != wp.sni {
t.Errorf("worker sni/dialHost wrong: sni=%q dialHost=%q", wp.sni, wp.dialHost)
}
}
func TestPlanTransports_WorkerForDC1NoNativeEdge(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "ws",
CFWorkerDomain: "w.user.workers.dev",
}
plans, err := planTransports(cfg, config.QueueConfig{}, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
found := false
for _, p := range plans {
if p.isWorker {
found = true
if p.wsPath != "/apiws?dst=149.154.175.50&dc=1" {
t.Errorf("unexpected DC1 worker path %q", p.wsPath)
}
}
}
if !found {
t.Fatal("expected a worker plan for DC1 (no native edge)")
}
}
func TestPlanTransports_MultipleWorkerDomains(t *testing.T) {
cfg := &config.MTProtoConfig{
UpstreamMode: "ws",
CFWorkerDomain: " a.workers.dev , b.workers.dev ",
}
plans, err := planTransports(cfg, config.QueueConfig{}, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
n := 0
for _, p := range plans {
if p.isWorker {
n++
}
}
if n != 2 {
t.Errorf("expected 2 worker plans (trimmed), got %d", n)
}
}
func TestPlanTransports_NoWorkerWhenUnset(t *testing.T) {
cfg := &config.MTProtoConfig{UpstreamMode: "ws"}
plans, err := planTransports(cfg, config.QueueConfig{}, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, p := range plans {
if p.isWorker {
t.Error("did not expect worker plans when CFWorkerDomain is empty")
}
}
}

View file

@ -17,23 +17,27 @@ import (
const (
maxConnections = 512
relayBufSize = 16384
relayBufSize = 65536
)
type Server struct {
cfg *config.Config
secret *Secret
connSem chan struct{}
bufPool sync.Pool
active atomic.Int64
cfg atomic.Pointer[config.Config]
secret atomic.Pointer[Secret]
wsPool atomic.Pointer[wsPool]
mu sync.Mutex
running bool
listener net.Listener
ctx context.Context
cancel context.CancelFunc
active atomic.Int64
connSem chan struct{}
bufPool sync.Pool
}
func NewServer(cfg *config.Config) *Server {
return &Server{
cfg: cfg,
s := &Server{
connSem: make(chan struct{}, maxConnections),
bufPool: sync.Pool{
New: func() interface{} {
@ -42,10 +46,19 @@ func NewServer(cfg *config.Config) *Server {
},
},
}
s.cfg.Store(cfg)
return s
}
func (s *Server) Start() error {
mtCfg := &s.cfg.System.MTProto
s.mu.Lock()
defer s.mu.Unlock()
return s.startLocked()
}
func (s *Server) startLocked() error {
cfg := s.cfg.Load()
mtCfg := &cfg.System.MTProto
if !mtCfg.Enabled {
log.Infof("MTProto proxy disabled")
return nil
@ -65,8 +78,8 @@ func (s *Server) Start() error {
return fmt.Errorf("MTProto generate secret: %w", err)
}
mtCfg.Secret = sec.Hex()
if s.cfg.ConfigPath != "" {
if err := s.cfg.SaveToFile(s.cfg.ConfigPath); err != nil {
if cfg.ConfigPath != "" {
if err := cfg.SaveToFile(cfg.ConfigPath); err != nil {
log.Warnf("MTProto: failed to persist generated secret: %v", err)
}
}
@ -74,43 +87,115 @@ func (s *Server) Start() error {
} else {
return fmt.Errorf("MTProto: either secret or fake_sni must be configured")
}
s.secret = sec
addr := net.JoinHostPort(mtCfg.BindAddress, strconv.Itoa(mtCfg.Port))
s.ctx, s.cancel = context.WithCancel(context.Background())
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("MTProto listen: %w", err)
}
s.listener = ln
s.secret.Store(sec)
s.ctx, s.cancel = context.WithCancel(context.Background())
log.Infof("MTProto proxy listening on %s (SNI: %s)", addr, sec.Host)
go s.acceptLoop()
if mode := mtCfg.UpstreamMode; mode == "ws" || mode == "auto" || mode == "" {
wsResetState()
tcpResetState()
pool := newWSPool(MTProtoUpstream{
WSEndpointHost: mtCfg.WSEndpointHost,
WSCustomDomain: mtCfg.WSCustomDomain,
}, cfg.Queue.Mark, wsPoolDefaultSize)
pool.warmup([]int{2, 4})
s.wsPool.Store(pool)
} else {
s.wsPool.Store(nil)
}
s.running = true
go s.acceptLoop(ln)
return nil
}
func (s *Server) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.stopLocked()
}
func (s *Server) stopLocked() error {
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
if pool := s.wsPool.Swap(nil); pool != nil {
pool.close()
}
var err error
if s.listener != nil {
return s.listener.Close()
err = s.listener.Close()
s.listener = nil
}
return nil
s.running = false
return err
}
func (s *Server) UpdateConfig(newCfg *config.Config) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cfg.Load()
s.cfg.Store(newCfg)
if old != nil && !mtprotoNeedsRestart(old, newCfg) {
return
}
wasEnabled := old != nil && old.System.MTProto.Enabled
if s.running {
_ = s.stopLocked()
}
if newCfg.System.MTProto.Enabled {
if err := s.startLocked(); err != nil {
log.Errorf("MTProto reload failed: %v (proxy stopped; fix in Settings)", err)
} else {
log.Infof("MTProto reloaded with updated configuration")
}
} else if wasEnabled {
log.Infof("MTProto proxy stopped (disabled in configuration)")
}
}
func mtprotoNeedsRestart(old, newCfg *config.Config) bool {
o := old.System.MTProto
n := newCfg.System.MTProto
if o.Enabled != n.Enabled ||
o.Port != n.Port ||
o.BindAddress != n.BindAddress ||
o.Secret != n.Secret ||
o.FakeSNI != n.FakeSNI ||
o.UpstreamMode != n.UpstreamMode ||
o.WSEndpointHost != n.WSEndpointHost ||
o.WSCustomDomain != n.WSCustomDomain ||
o.CFProxyEnabled != n.CFProxyEnabled ||
o.CFProxyURL != n.CFProxyURL {
return true
}
return old.Queue.Mark != newCfg.Queue.Mark
}
func (s *Server) GetSecret() string {
if s.secret != nil {
return s.secret.Hex()
if sec := s.secret.Load(); sec != nil {
return sec.Hex()
}
return ""
}
func (s *Server) acceptLoop() {
func (s *Server) acceptLoop(ln net.Listener) {
for {
conn, err := s.listener.Accept()
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
@ -127,6 +212,13 @@ func (s *Server) acceptLoop() {
continue
}
// match tg-ws-proxy: tune accepted client socket to 256KB buffers + nodelay
if tc, ok := conn.(*net.TCPConn); ok {
_ = tc.SetNoDelay(true)
_ = tc.SetReadBuffer(256 * 1024)
_ = tc.SetWriteBuffer(256 * 1024)
}
s.active.Add(1)
go func(c net.Conn) {
defer func() {
@ -149,22 +241,28 @@ func (s *Server) handleConn(raw net.Conn) {
}
}()
secret := s.secret.Load()
if secret == nil {
return
}
cfg := s.cfg.Load()
if err := raw.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
}
tlsConn, err := AcceptFakeTLS(raw, s.secret)
tlsConn, err := AcceptFakeTLS(raw, secret)
if err != nil {
log.Debugf("MTProto fake-TLS failed from %s: %v", clientAddr, err)
var vErr *FakeTLSVerifyError
if errors.As(err, &vErr) && s.cfg.System.MTProto.FakeSNI != "" {
proxyToMaskingDomain(raw, vErr.Initial, s.cfg.System.MTProto.FakeSNI, s.cfg.Queue.Mark)
if errors.As(err, &vErr) && cfg.System.MTProto.FakeSNI != "" {
proxyToMaskingDomain(raw, vErr.Initial, cfg.System.MTProto.FakeSNI, cfg.Queue.Mark)
}
return
}
log.Debugf("MTProto fake-TLS handshake OK from %s", clientAddr)
result, err := AcceptObfuscated(tlsConn, s.secret)
result, err := AcceptObfuscated(tlsConn, secret)
if err != nil {
log.Tracef("MTProto obfuscated2 failed from %s: %v", clientAddr, err)
return
@ -172,9 +270,14 @@ func (s *Server) handleConn(raw net.Conn) {
log.Debugf("MTProto client from %s wants DC %d proto=0x%08x", clientAddr, result.DC, result.ProtoTag)
_ = raw.SetDeadline(time.Time{})
dcConn, transport, err := DialObfuscatedDC(&s.cfg.System.MTProto, s.cfg.Queue, result.DC, result.ProtoTag)
dcConn, transport, err := DialObfuscatedDCWithPool(&cfg.System.MTProto, cfg.Queue, result.DC, result.ProtoTag, s.wsPool.Load())
if err != nil {
log.Errorf("MTProto dial DC %d: %v", result.DC, err)
// throttle ERROR-level spam from a permanently-broken DC; full detail still goes to Debug
if shouldLogDialError(result.DC) {
log.Errorf("MTProto dial DC %d: %v", result.DC, err)
} else {
log.Debugf("MTProto dial DC %d (suppressed): %v", result.DC, err)
}
return
}
defer dcConn.Close()
@ -189,19 +292,42 @@ func (s *Server) handleConn(raw net.Conn) {
}
func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string) {
errCh := make(chan error, 2)
relayConns(client, dc, splitter, label, &s.bufPool)
}
cp := func(dst io.Writer, src io.Reader, dir string) {
bufPtr := s.bufPool.Get().(*[]byte)
defer s.bufPool.Put(bufPtr)
n, err := io.CopyBuffer(dst, src, *bufPtr)
log.Debugf("MTProto relay %s %s: %d bytes, err=%v", label, dir, n, err)
func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string, bufPool *sync.Pool) {
errCh := make(chan error, 2)
start := time.Now()
var upBytes, downBytes atomic.Int64
cp := func(dst io.Writer, src io.Reader, dir string, counter *atomic.Int64) {
bufPtr := bufPool.Get().(*[]byte)
defer bufPool.Put(bufPtr)
buf := *bufPtr
var total int64
var err error
for {
var n int
n, err = src.Read(buf)
if n > 0 {
if _, werr := dst.Write(buf[:n]); werr != nil {
err = werr
} else {
total += int64(n)
}
}
if err != nil {
break
}
}
counter.Store(total)
log.Debugf("MTProto relay %s %s: %d bytes, err=%v", label, dir, total, err)
errCh <- err
}
cpSplit := func(dst io.Writer, src io.Reader, dir string) {
bufPtr := s.bufPool.Get().(*[]byte)
defer s.bufPool.Put(bufPtr)
cpSplit := func(dst io.Writer, src io.Reader, dir string, counter *atomic.Int64) {
bufPtr := bufPool.Get().(*[]byte)
defer bufPool.Put(bufPtr)
buf := *bufPtr
var total int64
var err error
@ -224,19 +350,21 @@ func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, lab
break
}
}
counter.Store(total)
log.Debugf("MTProto relay %s %s: %d bytes, err=%v", label, dir, total, err)
errCh <- err
}
if splitter != nil {
go cpSplit(dc, client, "client->DC")
go cpSplit(dc, client, "client->DC", &upBytes)
} else {
go cp(dc, client, "client->DC")
go cp(dc, client, "client->DC", &upBytes)
}
go cp(client, dc, "DC->client")
go cp(client, dc, "DC->client", &downBytes)
<-errCh
_ = client.Close()
_ = dc.Close()
<-errCh
log.Infof("MTProto session %s closed: up=%d down=%d in %dms", label, upBytes.Load(), downBytes.Load(), time.Since(start).Milliseconds())
}

171
src/mtproto/transparent.go Normal file
View file

@ -0,0 +1,171 @@
package mtproto
import (
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/log"
)
const transparentBufSize = 65536
type prefixConn struct {
net.Conn
prefix []byte
}
func (c *prefixConn) Read(p []byte) (int, error) {
if len(c.prefix) > 0 {
n := copy(p, c.prefix)
c.prefix = c.prefix[n:]
return n, nil
}
return c.Conn.Read(p)
}
func (c *prefixConn) CloseWrite() error {
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
return cw.CloseWrite()
}
return nil
}
type TransparentBridge struct {
cfg atomic.Pointer[config.Config]
bufPool sync.Pool
mu sync.Mutex
pool *wsPool
poolInit bool
}
func NewTransparentBridge(cfg *config.Config) *TransparentBridge {
b := &TransparentBridge{
bufPool: sync.Pool{New: func() interface{} {
buf := make([]byte, transparentBufSize)
return &buf
}},
}
b.cfg.Store(cfg)
return b
}
func (b *TransparentBridge) UpdateConfig(newCfg *config.Config) {
old := b.cfg.Swap(newCfg)
if old != nil &&
old.System.MTProto.WSEndpointHost == newCfg.System.MTProto.WSEndpointHost &&
old.System.MTProto.WSCustomDomain == newCfg.System.MTProto.WSCustomDomain &&
old.Queue.Mark == newCfg.Queue.Mark {
return
}
b.mu.Lock()
oldPool := b.pool
b.pool = nil
b.poolInit = false
b.mu.Unlock()
if oldPool != nil {
oldPool.close()
}
}
func (b *TransparentBridge) getPool() *wsPool {
b.mu.Lock()
defer b.mu.Unlock()
if !b.poolInit {
cfg := b.cfg.Load()
mt := cfg.System.MTProto
p := newWSPool(MTProtoUpstream{
WSEndpointHost: mt.WSEndpointHost,
WSCustomDomain: mt.WSCustomDomain,
}, cfg.Queue.Mark, wsPoolDefaultSize)
p.warmup([]int{2, 4})
b.pool = p
b.poolInit = true
}
return b.pool
}
func (b *TransparentBridge) Handle(client net.Conn, origIP net.IP, origPort int) (bool, net.Conn) {
_ = client.SetReadDeadline(time.Now().Add(5 * time.Second))
init := make([]byte, obfuscatedFrameLen)
head, herr := io.ReadFull(client, init[:4])
if herr != nil {
_ = client.SetReadDeadline(time.Time{})
if head == 0 {
return true, nil
}
return false, &prefixConn{Conn: client, prefix: append([]byte(nil), init[:head]...)}
}
if reservedFirst4(init[:4]) {
_ = client.SetReadDeadline(time.Time{})
log.Debugf("MTProto transparent: non-obfuscated transport (% x) from %s -> fail open", init[:4], origIP)
return false, &prefixConn{Conn: client, prefix: append([]byte(nil), init[:4]...)}
}
n, rerr := io.ReadFull(client, init[4:])
_ = client.SetReadDeadline(time.Time{})
if rerr != nil {
return false, &prefixConn{Conn: client, prefix: append([]byte(nil), init[:4+n]...)}
}
res, derr := decodeObfuscatedDirect(init, client)
if derr != nil {
log.Debugf("MTProto transparent: obfuscated decode failed from %s: %v -> fail open", origIP, derr)
return false, &prefixConn{Conn: client, prefix: append([]byte(nil), init...)}
}
var dc int
var dcSrc string
if mapped, ok := dcForIP(origIP); ok {
dc, dcSrc = mapped, "ip"
} else if mapped, ok := dcForIPRange(origIP); ok {
dc, dcSrc = mapped, "ip-range"
} else if validTransparentDC(res.DC) {
dc, dcSrc = res.DC, "handshake"
} else {
log.Debugf("MTProto transparent: unresolved DC for %s (handshake dc=%d proto=0x%08x) -> fail open", origIP, res.DC, res.ProtoTag)
return false, &prefixConn{Conn: client, prefix: append([]byte(nil), init...)}
}
cfg := b.cfg.Load()
mtCfg := cfg.System.MTProto
mtCfg.UpstreamMode = "auto"
mtCfg.DCRelay = ""
dcConn, transport, err := DialObfuscatedDCWithPool(&mtCfg, cfg.Queue, dc, res.ProtoTag, b.getPool())
if err != nil {
if shouldLogDialError(dc) {
log.Errorf("MTProto transparent dial DC %d: %v", dc, err)
} else {
log.Debugf("MTProto transparent dial DC %d (suppressed): %v", dc, err)
}
return true, nil
}
defer dcConn.Close()
label := fmt.Sprintf("%s<->DC%d(transparent)", client.RemoteAddr(), dc)
log.Infof("MTProto transparent relay: %s -> DC%d (%s) [dc-from=%s]", origIP, dc, transport, dcSrc)
var splitter *msgSplitter
if _, isWS := dcConn.Conn.(*wsConn); isWS {
splitter = newMsgSplitter(res.ProtoTag)
}
relayConns(res.Conn, dcConn, splitter, label, &b.bufPool)
return true, nil
}
func validTransparentDC(dc int) bool {
a := dc
if a < 0 {
a = -a
}
return (a >= 1 && a <= 5) || a == 203
}
func reservedFirst4(b []byte) bool {
return isReservedFirst4(b)
}

View file

@ -0,0 +1,189 @@
package mtproto
import (
"bytes"
"io"
"net"
"testing"
"time"
"github.com/daniellavrushin/b4/config"
)
func TestReservedFirst4(t *testing.T) {
reserved := [][]byte{
{0xef, 0x00, 0x00, 0x00},
{0x48, 0x45, 0x41, 0x44}, // HEAD
{0x50, 0x4f, 0x53, 0x54}, // POST
{0x47, 0x45, 0x54, 0x20}, // "GET "
{0x4f, 0x50, 0x54, 0x49}, // OPTI
{0x16, 0x03, 0x01, 0x02}, // TLS record header
{0xdd, 0xdd, 0xdd, 0xdd},
{0xee, 0xee, 0xee, 0xee},
}
for _, b := range reserved {
if !reservedFirst4(b) {
t.Errorf("reservedFirst4(% x) = false, want true", b)
}
}
// a plausible random obfuscated prefix must not be flagged
normal := []byte{0x01, 0x02, 0x03, 0x04}
if reservedFirst4(normal) {
t.Errorf("reservedFirst4(% x) = true, want false", normal)
}
}
func TestValidTransparentDC(t *testing.T) {
cases := []struct {
dc int
want bool
}{
{1, true}, {2, true}, {5, true}, {203, true},
{-1, true}, {-2, true}, {-5, true}, {-203, true},
{0, false}, {6, false}, {99, false}, {-99, false},
}
for _, c := range cases {
if got := validTransparentDC(c.dc); got != c.want {
t.Errorf("validTransparentDC(%d) = %v, want %v", c.dc, got, c.want)
}
}
}
func TestPrefixConnReplaysBeforePassthrough(t *testing.T) {
prefix := []byte("PREFIX")
body := []byte("BODY")
pc := &prefixConn{Conn: fakeConn{r: bytes.NewReader(body)}, prefix: append([]byte(nil), prefix...)}
got, err := io.ReadAll(pc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
want := append(append([]byte(nil), prefix...), body...)
if !bytes.Equal(got, want) {
t.Errorf("prefixConn read = %q, want %q", got, want)
}
}
func TestPrefixConnPartialReadDoesNotLosePrefix(t *testing.T) {
prefix := []byte("ABCDEF")
pc := &prefixConn{Conn: fakeConn{r: bytes.NewReader(nil)}, prefix: append([]byte(nil), prefix...)}
buf := make([]byte, 2)
var got []byte
for {
n, err := pc.Read(buf)
got = append(got, buf[:n]...)
if err != nil {
break
}
if len(got) >= len(prefix) {
break
}
}
if !bytes.Equal(got, prefix) {
t.Errorf("got %q, want %q", got, prefix)
}
}
// fakeConn is a minimal net.Conn backed by a reader (and optional write sink).
type fakeConn struct {
r io.Reader
w io.Writer
}
func (c fakeConn) Read(p []byte) (int, error) {
if c.r == nil {
return 0, io.EOF
}
return c.r.Read(p)
}
func (c fakeConn) Write(p []byte) (int, error) {
if c.w == nil {
return len(p), nil
}
return c.w.Write(p)
}
func (c fakeConn) Close() error { return nil }
func (c fakeConn) LocalAddr() net.Addr { return fakeAddr{} }
func (c fakeConn) RemoteAddr() net.Addr { return fakeAddr{} }
func (c fakeConn) SetDeadline(t time.Time) error { return nil }
func (c fakeConn) SetReadDeadline(t time.Time) error { return nil }
func (c fakeConn) SetWriteDeadline(t time.Time) error { return nil }
type fakeAddr struct{}
func (fakeAddr) Network() string { return "tcp" }
func (fakeAddr) String() string { return "1.2.3.4:5" }
func newBridge() *TransparentBridge {
return NewTransparentBridge(&config.Config{})
}
func TestHandleEmptyConnReturnsHandledNil(t *testing.T) {
// immediate EOF (head==0) -> connection closed, nothing to fail open with
b := newBridge()
handled, failover := b.Handle(fakeConn{r: bytes.NewReader(nil)}, net.ParseIP("1.2.3.4"), 443)
if !handled || failover != nil {
t.Fatalf("empty conn: got (handled=%v, failover=%v), want (true, nil)", handled, failover)
}
}
func TestHandleReservedPrefixFailsOpenWithBytes(t *testing.T) {
// non-obfuscated transport (TLS record header) -> fail open, replay the 4 bytes
b := newBridge()
in := []byte{0x16, 0x03, 0x01, 0x02, 0x00, 0xaa, 0xbb}
handled, failover := b.Handle(fakeConn{r: bytes.NewReader(in)}, net.ParseIP("1.2.3.4"), 443)
if handled {
t.Fatalf("reserved prefix should not be handled by bridge")
}
if failover == nil {
t.Fatal("reserved prefix: expected failover conn, got nil")
}
// failover is the original conn with the 4 read bytes re-prepended, so the
// whole original stream must be recoverable intact for the direct dial.
got, _ := io.ReadAll(failover)
if !bytes.Equal(got, in) {
t.Errorf("failover replayed % x, want full original stream % x", got, in)
}
}
func TestHandlePartialReadFailsOpenWithBytes(t *testing.T) {
// fewer than 4 bytes then EOF -> fail open replaying what arrived
b := newBridge()
in := []byte{0x01, 0x02}
handled, failover := b.Handle(fakeConn{r: bytes.NewReader(in)}, net.ParseIP("1.2.3.4"), 443)
if handled {
t.Fatalf("partial read should fail open, not be handled")
}
if failover == nil {
t.Fatal("partial read: expected failover conn, got nil")
}
got, _ := io.ReadAll(failover)
if !bytes.Equal(got, in) {
t.Errorf("failover replayed % x, want % x", got, in)
}
}
func TestHandleUnresolvedDCFailsOpenWithFullFrame(t *testing.T) {
// a full 64-byte obfuscated frame whose decoded DC is invalid and whose
// source IP maps to no DC -> fail open replaying all 64 bytes.
b := newBridge()
frame := make([]byte, obfuscatedFrameLen)
for i := range frame {
frame[i] = byte(i + 1) // non-reserved first 4 bytes (0x01..)
}
// ensure first4 is not accidentally reserved and byte0 != 0xef
if reservedFirst4(frame[:4]) {
t.Fatal("test frame unexpectedly reserved")
}
handled, failover := b.Handle(fakeConn{r: bytes.NewReader(frame)}, net.ParseIP("8.8.8.8"), 443)
if handled {
t.Fatalf("unresolved DC should fail open, not be handled")
}
if failover == nil {
t.Fatal("unresolved DC: expected failover conn, got nil")
}
got, _ := io.ReadAll(failover)
if len(got) != obfuscatedFrameLen || !bytes.Equal(got, frame) {
t.Errorf("failover replayed %d bytes, want full %d-byte frame intact", len(got), obfuscatedFrameLen)
}
}

View file

@ -62,6 +62,7 @@ func (c *wsConn) Read(p []byte) (int, error) {
c.rxBuf = c.rxBuf[n:]
return n, nil
}
var assembled []byte // accumulates fragmented data frames until FIN
for {
op, fin, payload, err := c.readFrame()
if err != nil {
@ -69,14 +70,34 @@ func (c *wsConn) Read(p []byte) (int, error) {
}
switch op {
case wsOpcodeBinary, 0x1:
// fragmented data frame: start accumulating; continuation frames will follow
if !fin {
return 0, errors.New("ws: fragmented data frames not supported")
assembled = append(assembled, payload...)
continue
}
n := copy(p, payload)
if n < len(payload) {
c.rxBuf = append(c.rxBuf, payload[n:]...)
full := payload
if len(assembled) > 0 {
full = append(assembled, payload...)
assembled = nil
}
n := copy(p, full)
if n < len(full) {
c.rxBuf = append(c.rxBuf, full[n:]...)
}
return n, nil
case 0x0: // continuation frame
if assembled == nil {
return 0, errors.New("ws: continuation frame without prior data frame")
}
assembled = append(assembled, payload...)
if fin {
n := copy(p, assembled)
if n < len(assembled) {
c.rxBuf = append(c.rxBuf, assembled[n:]...)
}
assembled = nil
return n, nil
}
case wsOpcodePing:
if err := c.writeFrame(wsOpcodePong, payload); err != nil {
return 0, err
@ -109,6 +130,59 @@ func (c *wsConn) Close() error {
return c.tls.Close()
}
// alive does a non-destructive liveness check on the conn. Pool entries can
// sit idle long enough for TG to FIN/RST them; handing such a conn to a client
// causes up=N down=0 short-lived sessions, which break short RPCs (notably
// auth.importAuthorization on secondary DCs - the exact path that makes
// foreign-channel media downloads fail). Cheap (~few ms) since FIN/RST is
// already in the kernel buffer if it happened.
func (c *wsConn) alive() bool {
if c.closed.Load() {
return false
}
if err := c.tls.SetReadDeadline(time.Now().Add(5 * time.Millisecond)); err != nil {
return false
}
defer func() { _ = c.tls.SetReadDeadline(time.Time{}) }()
buf, err := c.br.Peek(1)
if err == nil && len(buf) >= 1 {
// any buffered byte indicates the conn is alive; reject if it's a CLOSE frame
if buf[0]&0x0F == wsOpcodeClose {
return false
}
return true
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return true
}
return false
}
// liveNow is a zero-wait liveness poll used right after the obfuscated handshake
// is written to a pooled conn, to catch conns Telegram FIN/RST'd between the
// pool's alive() check and the relay's first write (the up=N down=0 in ~1ms
// failure). Uses an already-expired read deadline so Peek returns immediately:
// a pending FIN/RST surfaces as a non-timeout error (dead), an idle-but-open
// conn surfaces as a timeout (alive). Peek does not consume, so buffered data is
// preserved for the relay. Cost is microseconds, unlike alive()'s 5ms wait.
func (c *wsConn) liveNow() bool {
if c.closed.Load() {
return false
}
if err := c.tls.SetReadDeadline(time.Now().Add(-time.Second)); err != nil {
return false
}
defer func() { _ = c.tls.SetReadDeadline(time.Time{}) }()
buf, err := c.br.Peek(1)
if err == nil && len(buf) >= 1 {
return buf[0]&0x0F != wsOpcodeClose
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return true
}
return false
}
func (c *wsConn) LocalAddr() net.Addr { return c.tls.LocalAddr() }
func (c *wsConn) RemoteAddr() net.Addr { return c.tls.RemoteAddr() }
func (c *wsConn) SetDeadline(t time.Time) error {
@ -182,27 +256,25 @@ func (c *wsConn) writeFrame(op byte, payload []byte) error {
if _, err := rand.Read(hdr[off : off+4]); err != nil {
return err
}
maskKey := hdr[off : off+4]
off += 4
masked := make([]byte, n)
for i := range payload {
masked[i] = payload[i] ^ maskKey[i%4]
// single buffer + single tls.Write to avoid emitting two TLS records per frame
buf := make([]byte, off+n)
copy(buf, hdr[:off])
maskKey := buf[off-4 : off]
for i := 0; i < n; i++ {
buf[off+i] = payload[i] ^ maskKey[i%4]
}
c.wMu.Lock()
defer c.wMu.Unlock()
if _, err := c.tls.Write(hdr[:off]); err != nil {
return err
}
if n > 0 {
if _, err := c.tls.Write(masked); err != nil {
return err
}
}
return nil
_, err := c.tls.Write(buf)
return err
}
func dialWS(host, sni string, timeout time.Duration, mark uint) (net.Conn, error) {
func dialWS(host, sni, path string, timeout time.Duration, mark uint) (net.Conn, error) {
if path == "" {
path = "/apiws"
}
dialer := &net.Dialer{Timeout: timeout}
if mark > 0 {
dialer.Control = func(network, address string, c syscall.RawConn) error {
@ -221,10 +293,19 @@ func dialWS(host, sni string, timeout time.Duration, mark uint) (net.Conn, error
}
if tc, ok := raw.(*net.TCPConn); ok {
_ = tc.SetNoDelay(true)
// match tg-ws-proxy: 256KB send/recv buffers - kernel default (~87KB recv,
// ~16KB send) limits BDP for big media transfers from EU TG edge
_ = tc.SetReadBuffer(256 * 1024)
_ = tc.SetWriteBuffer(256 * 1024)
}
// Telegram's WS edge only presents proper certs for kws2/kws4; kws1/kws3/kws5
// fall back to a *.telegram.org cert that doesn't match the 3-label SNI.
// Cert verification adds no real security here - the MTProto payload is
// already end-to-end encrypted with the proxy secret. Match tg-ws-proxy.
tlsConn := tls.Client(raw, &tls.Config{
ServerName: sni,
MinVersion: tls.VersionTLS12,
ServerName: sni,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
})
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
if err := tlsConn.Handshake(); err != nil {
@ -239,7 +320,7 @@ func dialWS(host, sni string, timeout time.Duration, mark uint) (net.Conn, error
}
wsKey := base64.StdEncoding.EncodeToString(keyBytes)
req := "GET /apiws HTTP/1.1\r\n" +
req := "GET " + path + " HTTP/1.1\r\n" +
"Host: " + sni + "\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +

397
src/mtproto/wspool.go Normal file
View file

@ -0,0 +1,397 @@
package mtproto
import (
"context"
"net"
"strconv"
"sync"
"time"
"github.com/daniellavrushin/b4/log"
)
const (
wsPoolMaxAge = 20 * time.Second
wsPoolDefaultSize = 4
wsDCFailCooldown = 30 * time.Second
wsDialTimeoutCooldown = 2 * time.Second
tcpFailCooldown = 30 * time.Second
)
type wsKey struct {
dc int
isMedia bool
}
func (k wsKey) String() string {
s := strconv.Itoa(k.dc)
if k.isMedia {
s += "m"
}
return s
}
var (
wsStateMu sync.Mutex
wsBlacklist = map[wsKey]bool{}
wsCooldownTo = map[wsKey]time.Time{}
tcpStateMu sync.Mutex
tcpCooldownTo = map[string]time.Time{} // keyed by host:port
dialLogMu sync.Mutex
dialLogAt = map[int]time.Time{} // per-DC last full ERROR emit; throttles spam from known-broken DCs
)
const dialLogInterval = 60 * time.Second
// shouldLogDialError returns true if this is the first error for `dc` in the
// last dialLogInterval. Subsequent identical failures are silenced (caller can
// log at Debug instead) so a permanently-broken DC doesn't spam errors.log.
func shouldLogDialError(dc int) bool {
dialLogMu.Lock()
defer dialLogMu.Unlock()
now := time.Now()
if last, ok := dialLogAt[dc]; ok && now.Sub(last) < dialLogInterval {
return false
}
dialLogAt[dc] = now
return true
}
// per-addr TCP cooldown: skip an upstream IP/port that just timed out so
// every retrying client doesn't burn another tcpDialTimeout against it.
func tcpAddrInCooldown(addr string) bool {
tcpStateMu.Lock()
defer tcpStateMu.Unlock()
t, ok := tcpCooldownTo[addr]
if !ok {
return false
}
if time.Now().After(t) {
delete(tcpCooldownTo, addr)
return false
}
return true
}
func tcpRecordFailure(addr string) {
tcpStateMu.Lock()
defer tcpStateMu.Unlock()
tcpCooldownTo[addr] = time.Now().Add(tcpFailCooldown)
}
func tcpRecordSuccess(addr string) {
tcpStateMu.Lock()
defer tcpStateMu.Unlock()
delete(tcpCooldownTo, addr)
}
func tcpResetState() {
tcpStateMu.Lock()
defer tcpStateMu.Unlock()
tcpCooldownTo = map[string]time.Time{}
}
func wsKeyFromDC(dc int) wsKey {
abs := dc
if abs < 0 {
abs = -abs
}
return wsKey{dc: abs, isMedia: dc < 0}
}
func wsIsBlacklisted(dc int) bool {
k := wsKeyFromDC(dc)
wsStateMu.Lock()
defer wsStateMu.Unlock()
return wsBlacklist[k]
}
func wsCooldownActive(dc int) bool {
k := wsKeyFromDC(dc)
wsStateMu.Lock()
defer wsStateMu.Unlock()
t, ok := wsCooldownTo[k]
if !ok {
return false
}
if time.Now().After(t) {
delete(wsCooldownTo, k)
return false
}
return true
}
func wsRecordFailure(dc int, allRedirect bool) {
k := wsKeyFromDC(dc)
wsStateMu.Lock()
defer wsStateMu.Unlock()
if allRedirect {
wsBlacklist[k] = true
log.Warnf("MTProto WS %s blacklisted (all redirects)", k)
}
wsCooldownTo[k] = time.Now().Add(wsDCFailCooldown)
}
func wsRecordSuccess(dc int) {
k := wsKeyFromDC(dc)
wsStateMu.Lock()
defer wsStateMu.Unlock()
delete(wsCooldownTo, k)
delete(wsBlacklist, k)
}
func wsResetState() {
wsStateMu.Lock()
defer wsStateMu.Unlock()
wsBlacklist = map[wsKey]bool{}
wsCooldownTo = map[wsKey]time.Time{}
}
type wsPoolEntry struct {
conn *wsConn
created time.Time
}
type wsPool struct {
mu sync.Mutex
idle map[wsKey][]wsPoolEntry
refilling map[wsKey]bool
target int
maxAge time.Duration
cfg *MTProtoUpstream
mark uint
ctx context.Context
cancel context.CancelFunc
}
// MTProtoUpstream is the minimal upstream config the pool needs (subset of config.MTProtoConfig).
// Passed by value to detach pool from live config mutation.
type MTProtoUpstream struct {
WSEndpointHost string
WSCustomDomain string
}
func newWSPool(cfg MTProtoUpstream, mark uint, target int) *wsPool {
if target <= 0 {
target = wsPoolDefaultSize
}
ctx, cancel := context.WithCancel(context.Background())
return &wsPool{
idle: map[wsKey][]wsPoolEntry{},
refilling: map[wsKey]bool{},
target: target,
maxAge: wsPoolMaxAge,
cfg: &cfg,
mark: mark,
ctx: ctx,
cancel: cancel,
}
}
func (p *wsPool) close() {
p.cancel()
p.mu.Lock()
defer p.mu.Unlock()
for k, b := range p.idle {
for _, e := range b {
_ = e.conn.Close()
}
delete(p.idle, k)
}
}
// get returns a pre-warmed *wsConn for the given signed DC (negative = media),
// or nil if the pool is empty. On hit and miss it schedules an async refill so
// the next caller can also hit. The returned conn has had no obfuscated init
// sent yet - caller must run completeObfuscation on it.
func (p *wsPool) get(dc int) *wsConn {
if p == nil {
return nil
}
k := wsKeyFromDC(dc)
if !wsEdgeServesDC(k.dc) || wsIsBlacklisted(dc) {
return nil
}
p.mu.Lock()
bucket := p.idle[k]
now := time.Now()
var picked *wsConn
for len(bucket) > 0 {
e := bucket[0]
bucket = bucket[1:]
// stale check: TG may FIN/RST an idle conn server-side; handing such a conn
// to a client produces an up=N down=0 session (RPC sent, never answered).
// This is the path that breaks auth.importAuthorization on secondary DCs
// and makes foreign-channel media downloads hang.
if e.conn.closed.Load() || now.Sub(e.created) > p.maxAge || !e.conn.alive() {
go func(c *wsConn) { _ = c.Close() }(e.conn)
continue
}
picked = e.conn
break
}
p.idle[k] = bucket
p.mu.Unlock()
p.scheduleRefill(dc)
return picked
}
func (p *wsPool) scheduleRefill(dc int) {
if p == nil {
return
}
k := wsKeyFromDC(dc)
p.mu.Lock()
if p.refilling[k] {
p.mu.Unlock()
return
}
p.refilling[k] = true
p.mu.Unlock()
go p.refill(dc)
}
func (p *wsPool) refill(dc int) {
k := wsKeyFromDC(dc)
defer func() {
p.mu.Lock()
p.refilling[k] = false
p.mu.Unlock()
}()
if p.ctx.Err() != nil {
return
}
if wsIsBlacklisted(dc) {
return
}
p.mu.Lock()
need := p.target - len(p.idle[k])
p.mu.Unlock()
if need <= 0 {
return
}
// parallel dials so the pool fills in ~one RTT instead of need*RTT;
// individual failures don't abort siblings, matching tg-ws-proxy
type result struct {
conn *wsConn
err error
}
results := make(chan result, need)
for i := 0; i < need; i++ {
go func() {
if p.ctx.Err() != nil {
results <- result{}
return
}
c, err := p.dialFresh(dc)
results <- result{conn: c, err: err}
}()
}
added := 0
for i := 0; i < need; i++ {
r := <-results
if r.err != nil || r.conn == nil {
if r.err != nil {
log.Tracef("MTProto WS pool refill %s slot failed: %v", k, r.err)
}
continue
}
if p.ctx.Err() != nil {
_ = r.conn.Close()
continue
}
p.mu.Lock()
p.idle[k] = append(p.idle[k], wsPoolEntry{conn: r.conn, created: time.Now()})
p.mu.Unlock()
added++
}
if added > 0 {
log.Debugf("MTProto WS pool %s refilled +%d (target=%d)", k, added, p.target)
}
}
// dialFresh opens a raw WS connection (TLS + Upgrade) to a TG edge for `dc`.
// Tries both kwsN[-1] domains in the order matching media-vs-primary preference.
// Returns the first one to succeed, or the last error.
func (p *wsPool) dialFresh(dc int) (*wsConn, error) {
plans := wsPlansForDC(dc, p.cfg)
var lastErr error
for _, pl := range plans {
host := pl.dialHost
if host == "" {
host = pl.sni
}
conn, err := dialWS(host, pl.sni, pl.wsPath, wsDialTimeout, p.mark)
if err != nil {
lastErr = err
continue
}
if wsc, ok := conn.(*wsConn); ok {
return wsc, nil
}
_ = conn.Close()
}
if lastErr == nil {
lastErr = net.ErrClosed
}
return nil, lastErr
}
func wsPlansForDC(dc int, cfg *MTProtoUpstream) []transportPlan {
absDC := dc
if absDC < 0 {
absDC = -absDC
}
var plans []transportPlan
edgeIP := ""
if cfg != nil {
edgeIP = cfg.WSEndpointHost
}
if edgeIP == "" {
edgeIP = telegramWSEdgeIP
}
if wsEdgeServesDC(absDC) {
primary := transportPlan{kind: transportWS, dc: dc, sni: kwsHost(absDC, ""), dialHost: edgeIP}
media := transportPlan{kind: transportWS, dc: dc, sni: kwsHost(absDC, "-1"), dialHost: edgeIP}
if dc < 0 {
plans = append(plans, media, primary)
} else {
plans = append(plans, primary, media)
}
}
if cfg != nil && cfg.WSCustomDomain != "" {
plans = append(plans, transportPlan{
kind: transportWS,
dc: dc,
sni: kwsCustom(absDC, cfg.WSCustomDomain),
})
}
return plans
}
func kwsHost(dc int, suffix string) string {
return "kws" + strconv.Itoa(dc) + suffix + ".web.telegram.org"
}
func kwsCustom(dc int, domain string) string {
return "kws" + strconv.Itoa(dc) + "." + domain
}
func (p *wsPool) warmup(dcs []int) {
if p == nil {
return
}
for _, dc := range dcs {
p.scheduleRefill(dc)
p.scheduleRefill(-dc)
}
}

View file

@ -432,7 +432,7 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
m.RecordPacket(uint64(len(pkt.raw)))
}
if matched && set != nil && set.Routing.Enabled && set.Routing.Mode == config.RoutingModeProxy {
if matched && set != nil && set.Routing.Enabled && config.RoutingUsesTProxy(set.Routing.Mode) {
return accept(q, id)
}

View file

@ -125,7 +125,7 @@ func RoutingHandleDNS(cfg *config.Config, set *config.SetConfig, ips []net.IP) {
if _, ok := routeRuleCache[set.Id]; !ok {
var err error
if cur.mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(cur.mode) {
err = routeEnsureProxyRule(be, cfg, set, cur, sources)
} else {
err = routeEnsureRule(be, cfg, set, cur, sources)
@ -135,9 +135,12 @@ func RoutingHandleDNS(cfg *config.Config, set *config.SetConfig, ips []net.IP) {
return
}
routeRuleCache[set.Id] = cur
if cur.mode == config.RoutingModeProxy {
switch cur.mode {
case config.RoutingModeMTProtoWS:
log.Infof("Routing [%s]: enabled MTProto-WS set '%s' mark=0x%x port=%d", be.name(), set.Name, cur.mark, cur.tproxyPort)
case config.RoutingModeProxy:
log.Infof("Routing [%s]: enabled proxy set '%s' -> %s:%d mark=0x%x port=%d", be.name(), set.Name, set.Routing.Upstream.Host, set.Routing.Upstream.Port, cur.mark, cur.tproxyPort)
} else {
default:
log.Infof("Routing [%s]: enabled set '%s' -> iface=%s mark=0x%x table=%d", be.name(), set.Name, set.Routing.EgressInterface, cur.mark, cur.table)
}
}
@ -167,7 +170,7 @@ func buildRouteState(cfg *config.Config, set *config.SetConfig) routeState {
chainPre: chainPre, chainOut: chainOut, chainSNAT: chainSNAT,
}
if mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(mode) {
mark, port := proxyMarkAndPort(set)
st.mark = mark
st.table = proxyTable()
@ -193,7 +196,7 @@ func routeStateEqual(a, b routeState) bool {
}
func routeCleanupAny(be routeBackend, st routeState) {
if st.mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(st.mode) {
routeCleanupProxyRule(be, st)
return
}
@ -389,7 +392,7 @@ func RoutingSyncConfig(cfg *config.Config) {
if _, ok := routeRuleCache[set.Id]; !ok {
var err error
if cur.mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(cur.mode) {
err = routeEnsureProxyRule(be, cfg, set, cur, sources)
} else {
err = routeEnsureRule(be, cfg, set, cur, sources)
@ -413,7 +416,7 @@ func RoutingSyncConfig(cfg *config.Config) {
routeIfaceAuto = make(map[string]routeState)
for _, st := range routeRuleCache {
if st.mode == config.RoutingModeProxy || st.iface == "" {
if config.RoutingUsesTProxy(st.mode) || st.iface == "" {
continue
}
if _, ok := routeIfaceAuto[st.iface]; !ok {
@ -588,7 +591,7 @@ func routeAddMasqueradeRules(be routeBackend, iface, chain string, mark uint32,
func interfaceShareCount(mark uint32, table int) int {
n := 0
for _, st := range routeRuleCache {
if st.mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(st.mode) {
continue
}
if st.mark == mark && st.table == table {
@ -676,7 +679,7 @@ func RoutingReinstallForInterface(cfg *config.Config, iface string) {
ipv6 := cfg.Queue.IPv6Enabled
count := 0
for _, st := range routeRuleCache {
if st.mode == config.RoutingModeProxy || st.iface != iface {
if config.RoutingUsesTProxy(st.mode) || st.iface != iface {
continue
}
routeEnsurePolicyRouting(st.iface, st.mark, st.table, ipv4, ipv6)

View file

@ -89,7 +89,7 @@ func proxyTable() int {
func proxyActiveCount() int {
n := 0
for _, st := range routeRuleCache {
if st.mode == config.RoutingModeProxy {
if config.RoutingUsesTProxy(st.mode) {
n++
}
}

View file

@ -26,6 +26,10 @@ type DomainResolver interface {
DomainFor(ip net.IP) string
}
type MTProtoBridge interface {
Handle(client net.Conn, origIP net.IP, origPort int) (bool, net.Conn)
}
type Listener struct {
SetID string
SetName string
@ -36,6 +40,8 @@ type Listener struct {
UseDomain bool
FailOpen bool
Resolver DomainResolver
MTProtoWS bool
Bridge MTProtoBridge
ctx context.Context
cancel context.CancelFunc
@ -170,6 +176,25 @@ func (l *Listener) handle(client net.Conn) {
origIP := tcpAddr.IP
origPort := tcpAddr.Port
if l.MTProtoWS {
src := ""
if r := client.RemoteAddr(); r != nil {
src = r.String()
}
log.LogConnectionStr("TCP", l.SetName, "", src, "",
net.JoinHostPort(origIP.String(), fmt.Sprintf("%d", origPort)),
"", "", "mtproto-ws")
if l.Bridge != nil {
if handled, failover := l.Bridge.Handle(client, origIP, origPort); handled {
return
} else if failover != nil {
client = failover
}
}
l.failOpenDirect(client, origIP, origPort)
return
}
domain := ""
if l.Resolver != nil {
domain = l.Resolver.DomainFor(origIP)
@ -213,20 +238,33 @@ func (l *Listener) handle(client net.Conn) {
pipe(client, upstream)
}
func (l *Listener) failOpenDirect(client net.Conn, origIP net.IP, origPort int) {
dialer := markedDialer(10*time.Second, l.Upstream.BypassMark)
ctx, cancel := context.WithTimeout(l.ctx, 10*time.Second)
direct, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(origIP.String(), fmt.Sprintf("%d", origPort)))
cancel()
if err != nil {
log.Tracef("tproxy: mtproto-ws fail-open direct dial failed for %s:%d: %v", origIP, origPort, err)
return
}
defer direct.Close()
pipe(client, direct)
}
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(a, b)
if c, ok := a.(*net.TCPConn); ok {
if c, ok := a.(interface{ CloseWrite() error }); ok {
_ = c.CloseWrite()
}
}()
go func() {
defer wg.Done()
_, _ = io.Copy(b, a)
if c, ok := b.(*net.TCPConn); ok {
if c, ok := b.(interface{ CloseWrite() error }); ok {
_ = c.CloseWrite()
}
}()

View file

@ -11,11 +11,21 @@ import (
)
type Manager struct {
mu sync.Mutex
listeners map[string]*Listener
resolver DomainResolver
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
listeners map[string]*Listener
resolver DomainResolver
mtprotoBridge MTProtoBridge
ctx context.Context
cancel context.CancelFunc
}
func (m *Manager) SetMTProtoBridge(b MTProtoBridge) {
m.mu.Lock()
defer m.mu.Unlock()
m.mtprotoBridge = b
for _, l := range m.listeners {
l.Bridge = b
}
}
func NewManager(resolver DomainResolver) *Manager {
@ -52,7 +62,7 @@ func (m *Manager) SyncConfig(cfg *config.Config) {
if set == nil || !set.Enabled || !set.Routing.Enabled {
continue
}
if set.Routing.Mode != config.RoutingModeProxy {
if !config.RoutingUsesTProxy(set.Routing.Mode) {
continue
}
desired[set.Id] = set
@ -72,7 +82,9 @@ func (m *Manager) SyncConfig(cfg *config.Config) {
if desiredHost == "" {
desiredHost = "127.0.0.1"
}
isMTWS := set.Routing.Mode == config.RoutingModeMTProtoWS
if l.Port != port ||
l.MTProtoWS != isMTWS ||
l.Upstream.Host != desiredHost ||
l.Upstream.Port != set.Routing.Upstream.Port ||
l.Upstream.Username != set.Routing.Upstream.Username ||
@ -111,6 +123,8 @@ func (m *Manager) SyncConfig(cfg *config.Config) {
UseDomain: set.Routing.Upstream.UseDomain,
FailOpen: set.Routing.Upstream.FailOpen,
Resolver: m.resolver,
MTProtoWS: set.Routing.Mode == config.RoutingModeMTProtoWS,
Bridge: m.mtprotoBridge,
}
if err := l.Start(m.ctx); err != nil {
log.Errorf("tproxy: failed to start listener for set %q: %v", set.Name, err)