feat: implement tunable MTProto connection timeouts and improve sessi… (#269)

This commit is contained in:
Daniel Lavrushin 2026-07-05 00:12:53 +02:00 committed by GitHub
parent 463809e12c
commit dcc0fffa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 6812 additions and 546 deletions

View file

@ -1,5 +1,12 @@
# B4 - Bye Bye Big Bro
## [1.73.0] - 2026-07-05
- FIXED: **A phone that dropped off mobile data could leave a Telegram proxy connection stuck** - when a mobile client backgrounded Telegram or lost signal, its connection frequently died without a clean close, yet b4 held the half-dead session and its link out to Telegram open for many minutes, in some cases until the app was reopened, so returning to Telegram could mean waiting on a dead socket instead of a clean reconnect.
- FIXED: **A stalled link out to Telegram over a WebSocket bridge could wedge a proxy session** - if that link stalled mid-transfer, tearing the session down could itself block for many minutes on the stuck write, holding the client's slot and both connections until a kernel timeout expired.
- ADDED: **Tunable MTProto connection timeouts** - Settings → MTProto exposes a TCP user timeout and an idle timeout for the proxy, for unusual carrier networks or router kernels where the built-in defaults (about two minutes and five minutes) need adjusting or turning off.
- ADDED: **API to list active MTProto sessions and clients** - two HTTP endpoints for integrations: GET /api/mtproto/sessions returns one entry per live client connection (client IP and port, the Telegram destination, and when it connected and was last active), and GET /api/mtproto/active-clients groups those by secret (active connection count and the distinct client IPs using each one). The API exposed only a per-secret connection count, with no way to see the client addresses behind it.
## [1.72.0] - 2026-07-02
- ADDED: **A separate MTProto secret for each person** - the MTProto proxy can now hold several named secrets instead of one shared code. Each one can be named (for example, per person), switched on or off on its own, and shared as its own link or QR code, so access can be given or taken away one person at a time.

View file

@ -1,5 +1,12 @@
# B4 - Bye Bye Big Bro
## [1.73.0] - 2026-07-05
- ИСПРАВЛЕНО: **Телефон, отвалившийся от мобильного интернета, мог оставить подключение к прокси Telegram зависшим** - когда мобильный клиент сворачивал Telegram или терял сигнал, его соединение часто обрывалось без чистого закрытия, но b4 удерживал полумёртвую сессию и свой канал до Telegram открытыми много минут, иногда до повторного открытия приложения, поэтому при возврате в Telegram можно было упереться в мёртвый сокет вместо чистого переподключения.
- ИСПРАВЛЕНО: **Зависший канал до Telegram через WebSocket-мост мог заклинить сессию прокси** - если этот канал застревал посреди передачи, само закрытие сессии могло заблокироваться на много минут на застрявшей записи, удерживая слот клиента и оба соединения до истечения таймаута ядра.
- ДОБАВЛЕНО: **Настраиваемые таймауты подключений MTProto** - в «Настройки → MTProto» появились TCP user timeout и таймаут простоя для прокси, на случай нестандартных операторских сетей или ядер роутеров, где встроенные значения по умолчанию (около двух и пяти минут) нужно подправить или отключить.
- ДОБАВЛЕНО: **API для просмотра активных сессий и клиентов MTProto** - два HTTP-эндпоинта для интеграций: GET /api/mtproto/sessions возвращает по одной записи на каждое живое клиентское соединение (IP и порт клиента, адрес назначения в Telegram, время подключения и последней активности), а GET /api/mtproto/active-clients группирует их по секрету (число активных соединений и различные IP клиентов, использующих каждый). API отдавал только число соединений на секрет, без возможности увидеть адреса клиентов за ним.
## [1.72.0] - 2026-07-02
- ДОБАВЛЕНО: **Отдельный секрет MTProto для каждого человека** - прокси MTProto теперь может хранить несколько именованных секретов вместо одного общего кода. Каждому можно задать имя (например, по человеку), включить или выключить его отдельно и поделиться им отдельной ссылкой или QR-кодом, поэтому доступ можно выдавать или отзывать по одному человеку.

View file

@ -25,19 +25,74 @@ function buildSwaggerHtml(specUrl: string, server: string): string {
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
const API_BASE = ${JSON.stringify(server)};
let tokenCache = { key: "", token: "" };
async function ensureBearer(req) {
const auth = req.headers && req.headers.Authorization;
if (!auth) return req;
if (/^Bearer\\s/i.test(auth)) return req;
if (/^Basic\\s/i.test(auth)) {
const b64 = auth.replace(/^Basic\\s+/i, "");
let decoded = "";
try { decoded = atob(b64); } catch (e) { return req; }
const idx = decoded.indexOf(":");
const username = idx >= 0 ? decoded.slice(0, idx) : decoded;
const password = idx >= 0 ? decoded.slice(idx + 1) : "";
if (tokenCache.key === b64 && tokenCache.token) {
req.headers.Authorization = "Bearer " + tokenCache.token;
return req;
}
if (!API_BASE) { delete req.headers.Authorization; return req; }
try {
const resp = await fetch(API_BASE.replace(/\\/$/, "") + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data && data.token) {
tokenCache = { key: b64, token: data.token };
req.headers.Authorization = "Bearer " + data.token;
} else {
delete req.headers.Authorization;
}
} catch (e) {
delete req.headers.Authorization;
}
return req;
}
req.headers.Authorization = "Bearer " + auth;
return req;
}
fetch(${JSON.stringify(specUrl)})
.then(r => r.json())
.then(spec => {${serverScript}
spec.securityDefinitions = spec.securityDefinitions || {};
spec.securityDefinitions.BasicAuth = {
type: "basic",
description: "Enter your b4 web UI username and password",
};
if (spec.paths) {
Object.keys(spec.paths).forEach(function (p) {
const item = spec.paths[p];
Object.keys(item).forEach(function (m) {
const op = item[m];
if (op && Array.isArray(op.security)) {
const hasBearer = op.security.some(function (s) { return s && s.BearerAuth; });
const hasBasic = op.security.some(function (s) { return s && s.BasicAuth; });
if (hasBearer && !hasBasic) {
op.security.push({ BasicAuth: [] });
}
}
});
});
}
SwaggerUIBundle({
spec,
dom_id: "#swagger-ui",
requestInterceptor: (req) => {
const auth = req.headers && req.headers.Authorization;
if (auth && !/^Bearer\\s/i.test(auth)) {
req.headers.Authorization = "Bearer " + auth;
}
return req;
},
requestInterceptor: ensureBearer,
});
});
</script>

View file

@ -1,4 +1,6 @@
[
"v1.73.0"
,
"v1.71.0"
,
"v1.67.2"

5695
docs/static/swagger-versions/v1.73.0.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"description": "B4 network packet processor REST API",
"title": "B4 API",
"contact": {},
"version": "1.71.0"
"version": "1.73.0"
},
"basePath": "/api",
"paths": {
@ -2230,6 +2230,34 @@
}
}
},
"/mtproto/active-clients": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Aggregated per-secret view: active connection count, the distinct client IPs currently using each secret, and last activity.",
"produces": [
"application/json"
],
"tags": [
"MTProto"
],
"summary": "List active MTProto clients per secret",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
},
"/mtproto/config": {
"get": {
"security": [
@ -2355,6 +2383,34 @@
}
}
},
"/mtproto/sessions": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "One entry per live client connection, including client IP/port, upstream destination and activity timestamps.",
"produces": [
"application/json"
],
"tags": [
"MTProto"
],
"summary": "List active MTProto sessions",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
},
"/mtproto/test-ws": {
"post": {
"security": [
@ -3877,6 +3933,9 @@
"fake_sni": {
"type": "string"
},
"idle_timeout_sec": {
"type": "integer"
},
"max_connections": {
"description": "max concurrent client connections; 0 = default (2048)",
"type": "integer"
@ -3885,8 +3944,18 @@
"type": "integer"
},
"secret": {
"description": "legacy single secret; mirrors the first enabled entry in Secrets for backward compatibility",
"type": "string"
},
"secrets": {
"type": "array",
"items": {
"$ref": "#/definitions/config.MTProtoSecret"
}
},
"tcp_user_timeout_sec": {
"type": "integer"
},
"upstream_mode": {
"type": "string"
},
@ -3898,6 +3967,23 @@
}
}
},
"config.MTProtoSecret": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"secret": {
"type": "string"
}
}
},
"config.MasqueradeConfig": {
"type": "object",
"properties": {
@ -4227,6 +4313,9 @@
"config.TargetsConfig": {
"type": "object",
"properties": {
"domain_only": {
"type": "boolean"
},
"geoip_categories": {
"type": "array",
"items": {
@ -4261,6 +4350,9 @@
"type": "string"
}
},
"source_devices_exclude": {
"type": "boolean"
},
"tls": {
"description": "\"1.2\", \"1.3\", or \"\" (match any)",
"type": "string"
@ -4612,12 +4704,18 @@
"is_manual": {
"type": "boolean"
},
"is_online": {
"type": "boolean"
},
"is_private": {
"type": "boolean"
},
"mac": {
"type": "string"
},
"mss_clamp": {
"type": "integer"
},
"vendor": {
"type": "string"
}
@ -4678,6 +4776,32 @@
}
}
},
"handler.DiagCapability": {
"type": "object",
"properties": {
"available": {
"type": "boolean"
},
"detail": {
"type": "string"
},
"missing": {
"type": "array",
"items": {
"type": "string"
}
},
"name": {
"type": "string"
},
"packages": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handler.DiagEngine": {
"type": "object",
"properties": {
@ -4764,6 +4888,12 @@
"handler.DiagKernel": {
"type": "object",
"properties": {
"capabilities": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.DiagCapability"
}
},
"modules": {
"type": "array",
"items": {

View file

@ -8,89 +8,41 @@ import (
"path/filepath"
"strings"
"github.com/daniellavrushin/b4/geodat"
"github.com/daniellavrushin/b4/log"
"github.com/google/uuid"
)
type MigrationFunc func(*Config, map[string]interface{}) error
var (
CurrentConfigVersion = len(migrationRegistry)
const (
CurrentConfigVersion = 51
MinSupportedVersion = 0
)
var migrationRegistry = map[int]MigrationFunc{
0: migrateV0to1, // Add enabled field to sets
1: migrateV1to2,
2: migrateV2to3,
3: migrateV3to4,
4: migrateV4to5,
5: migrateV5to6,
6: migrateV6to7, // Add TCP syn TTL and drop SACK settings
7: migrateV7to8, // Add DNS redirect settings
8: migrateV8to9,
9: migrateV9to10,
10: migrateV10to11,
11: migrateV11to12,
12: migrateV12to13,
13: migrateV13to14,
14: migrateV14to15, // Flatten TCP desync settings into nested struct
15: migrateV15to16, // Add TCP Incoming config
16: migrateV16to17,
17: migrateV17to18, // Add TCP packet duplication config
18: migrateV18to19, // Add TLS certificate/key to web server config
19: migrateV19to20, // Add vendor lookup option to devices config
20: migrateV20to21, // Add SOCKS5 proxy server config
21: migrateV21to22, // Add NAT masquerade config
22: migrateV22to23, // Add TCP MSS clamping config
23: migrateV23to24, // Add multidisorder (fake per segment) and new payload types
24: migrateV24to25, // Remove main set, move ConnBytesLimit to queue config
25: migrateV25to26, // Add TLS version filter to targets
26: migrateV26to27, // Add tables engine config
27: migrateV27to28, // Add per-set routing config
28: migrateV28to29, // Add position ranges and strategy pool
29: migrateV29to30, // Add MTProto proxy config
30: migrateV30to31, // Add TCP IP block detection config
31: migrateV31to32, // Add watchdog config
32: migrateV32to33, // Add TCP RST protection config
33: migrateV33to34, // Add manual devices to device config
34: migrateV34to35, // Add routing mode and upstream proxy config
35: migrateV35to36, // Add fragmentation seq overlap length
36: migrateV36to37, // Add UDP fake_payload_file field
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
42: migrateV42to43, // Add per-set routing block action
43: migrateV43to44, // Add MTProto DC-list fallback source config
44: migrateV44to45, // Add per-set DNS-over-HTTPS redirect target
45: migrateV45to46, // Replace logging.error_file with logging.directory
46: migrateV46to47, // Drop the hardcoded legacy MTProto WS endpoint host (empty now falls back to it)
47: migrateV47to48, // Add TUN engine mode and config
48: migrateV48to49, // Convert masquerade to nested config (multi-interface)
49: migrateV49to50, // Move the single MTProto secret into the secrets list
50: migrateV50to51, // Add per-set domain-only matching target
14: migrateV14to15,
24: migrateV24to25,
33: migrateV33to34,
45: migrateV45to46,
46: migrateV46to47,
48: migrateV48to49,
49: migrateV49to50,
}
func migrateV50to51(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v50->v51: Adding per-set domain-only matching target")
for _, set := range c.Sets {
set.Targets.DomainOnly = false
set.Targets.SourceDevicesExclude = false
}
return nil
}
func migrateV49to50(c *Config, _ map[string]interface{}) error {
func migrateV49to50(c *Config, raw map[string]interface{}) error {
log.Tracef("Migration v49->v50: Moving single MTProto secret into secrets list")
m := &c.System.MTProto
if len(m.Secrets) == 0 && strings.TrimSpace(m.Secret) != "" {
if len(m.Secrets) > 0 {
return nil
}
system, _ := raw["system"].(map[string]interface{})
mt, _ := system["mtproto"].(map[string]interface{})
legacy, _ := mt["secret"].(string)
if strings.TrimSpace(legacy) != "" {
m.Secrets = []MTProtoSecret{{
ID: uuid.NewString(),
Name: "default",
Secret: m.Secret,
Secret: strings.TrimSpace(legacy),
Enabled: true,
}}
}
@ -107,13 +59,6 @@ func migrateV48to49(c *Config, raw map[string]interface{}) error {
return nil
}
func migrateV47to48(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v47->v48: Adding TUN engine mode and config")
c.Queue.Mode = DefaultConfig.Queue.Mode
c.Queue.TUN = DefaultConfig.Queue.TUN
return nil
}
func migrateV46to47(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v46->v47: Dropping hardcoded legacy MTProto WS endpoint host (empty falls back to the same edge)")
if c.System.MTProto.WSEndpointHost == TGWSEndpointHost {
@ -143,93 +88,6 @@ func migrateV45to46(c *Config, raw map[string]interface{}) error {
return nil
}
func migrateV44to45(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v44->v45: Adding per-set DNS-over-HTTPS redirect target")
for _, set := range c.Sets {
set.DNS.DoHURL = DefaultSetConfig.DNS.DoHURL
}
return nil
}
func migrateV43to44(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v43->v44: Adding MTProto DC-list fallback source config")
c.System.MTProto.DCFallbackEnabled = true
c.System.MTProto.DCFallbackURL = DefaultConfig.System.MTProto.DCFallbackURL
return nil
}
func migrateV42to43(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v42->v43: Adding per-set routing block action")
for _, set := range c.Sets {
if set.Routing.BlockAction == "" {
set.Routing.BlockAction = DefaultSetConfig.Routing.BlockAction
}
}
return nil
}
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 {
c.System.Geo.AutoUpdate = geodat.GeoAutoUpdateConfig{}
for _, set := range c.Sets {
set.MSSClamp = MSSClampConfig{}
}
return nil
}
func migrateV38to39(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v38->v39: Adding system.memory_limit")
c.System.MemoryLimit = DefaultConfig.System.MemoryLimit
return nil
}
func migrateV37to38(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v37->v38: Adding MTProto upstream transport (WS) fields")
c.System.MTProto.UpstreamMode = "auto"
c.System.MTProto.WSCustomDomain = ""
c.System.MTProto.WSEndpointHost = "149.154.167.220"
return nil
}
func migrateV36to37(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v36->v37: Adding UDP fake_payload_file field")
for _, set := range c.Sets {
set.UDP.FakePayloadFile = DefaultSetConfig.UDP.FakePayloadFile
}
return nil
}
func migrateV35to36(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v35->v36: Adding fragmentation seq overlap length")
for _, set := range c.Sets {
set.Fragmentation.SeqOverlapLength = DefaultSetConfig.Fragmentation.SeqOverlapLength
}
return nil
}
func migrateV34to35(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v34->v35: Adding routing mode and upstream proxy config")
for _, set := range c.Sets {
if set.Routing.Mode == "" {
set.Routing.Mode = RoutingModeInterface
}
set.Routing.Upstream.UseDomain = true
}
return nil
}
func migrateV33to34(c *Config, raw map[string]interface{}) error {
log.Tracef("Migration v33->v34: Unifying device model")
@ -353,74 +211,6 @@ func loadAliasesForMigration(configPath string) map[string]string {
return aliases
}
func migrateV32to33(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v32->v33: Adding TCP RST protection config")
for _, set := range c.Sets {
set.TCP.RSTProtection = DefaultSetConfig.TCP.RSTProtection
}
return nil
}
func migrateV31to32(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v31->v32: Adding watchdog config")
c.System.Checker.Watchdog = DefaultConfig.System.Checker.Watchdog
return nil
}
func migrateV30to31(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v30->v31: Adding TCP IP block detection config")
for _, set := range c.Sets {
set.TCP.IPBlockDetect = DefaultSetConfig.TCP.IPBlockDetect
}
return nil
}
func migrateV29to30(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v29->v30: Adding MTProto proxy config")
c.System.MTProto = DefaultConfig.System.MTProto
return nil
}
func migrateV28to29(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v28->v29: Adding position ranges and strategy pool")
for _, set := range c.Sets {
if set.Fragmentation.StrategyPool == nil {
set.Fragmentation.StrategyPool = []string{}
}
}
return nil
}
func migrateV27to28(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v27->v28: Adding per-set routing config")
for _, set := range c.Sets {
if set.Routing.SourceInterfaces == nil {
set.Routing.SourceInterfaces = []string{}
}
if set.Routing.IPTTLSeconds <= 0 {
set.Routing.IPTTLSeconds = DefaultSetConfig.Routing.IPTTLSeconds
}
}
return nil
}
// Migration: v26 -> v27 (add tables engine config)
func migrateV26to27(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v26->v27: Adding tables engine config")
c.System.Timezone = DefaultConfig.System.Timezone
c.System.Tables.Engine = DefaultConfig.System.Tables.Engine
return nil
}
// Migration: v25 -> v26 (add TLS version filter to targets)
func migrateV25to26(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v25->v26: Adding TLS version filter to targets")
for _, set := range c.Sets {
set.Targets.TLSVersion = ""
}
return nil
}
// Migration: v24 -> v25 (remove main set, move ConnBytesLimit to queue config)
func migrateV24to25(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v24->v25: Removing main set, moving ConnBytesLimit to queue config")
@ -448,73 +238,6 @@ func migrateV24to25(c *Config, _ map[string]interface{}) error {
return nil
}
func migrateV23to24(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v23->v24: Adding multidisorder (fake per segment) to combo/disorder configs")
for _, set := range c.Sets {
set.Fragmentation.Combo.FakePerSegment = DefaultSetConfig.Fragmentation.Combo.FakePerSegment
set.Fragmentation.Combo.FakePerSegCount = DefaultSetConfig.Fragmentation.Combo.FakePerSegCount
set.Fragmentation.Disorder.FakePerSegment = DefaultSetConfig.Fragmentation.Disorder.FakePerSegment
set.Fragmentation.Disorder.FakePerSegCount = DefaultSetConfig.Fragmentation.Disorder.FakePerSegCount
}
return nil
}
func migrateV22to23(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v22->v23: Adding queue-level MSS clamping config")
c.Queue.MSSClamp = DefaultConfig.Queue.MSSClamp
return nil
}
func migrateV21to22(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v21->v22: Adding NAT masquerade config")
c.System.Tables.Masquerade = DefaultConfig.System.Tables.Masquerade
return nil
}
func migrateV20to21(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v20->v21: Adding SOCKS5 proxy server config")
c.System.Socks5 = DefaultConfig.System.Socks5
return nil
}
func migrateV19to20(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v19->v20: Adding vendor lookup option to devices config")
c.Queue.Devices.VendorLookup = false
return nil
}
func migrateV18to19(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v18->v19: Adding TLS certificate/key fields to web server config")
// TLS fields default to empty strings (TLS disabled), no action needed
return nil
}
func migrateV17to18(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v17->v18: Adding TCP packet duplication config")
for _, set := range c.Sets {
set.TCP.Duplicate = DefaultSetConfig.TCP.Duplicate
}
return nil
}
func migrateV16to17(c *Config, _ map[string]interface{}) error {
for _, set := range c.Sets {
set.Faking.TimestampDecrease = DefaultSetConfig.Faking.TimestampDecrease
}
return nil
}
func migrateV15to16(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v15->v16: Adding TCP Incoming config")
for _, set := range c.Sets {
set.TCP.Incoming = DefaultSetConfig.TCP.Incoming
}
return nil
}
func migrateV14to15(c *Config, raw map[string]interface{}) error {
sets, _ := raw["sets"].([]interface{})
@ -558,158 +281,6 @@ func migrateV14to15(c *Config, raw map[string]interface{}) error {
return nil
}
// Migration: v13 -> v14 (add fragmentation strategy field)
func migrateV13to14(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v13->v14: Adding fragmentation strategy field")
c.System.WebServer.BindAddress = DefaultConfig.System.WebServer.BindAddress
for _, set := range c.Sets {
set.TCP.Desync.PostDesync = DefaultSetConfig.TCP.Desync.PostDesync
set.Fragmentation.Combo.DecoyEnabled = DefaultSetConfig.Fragmentation.Combo.DecoyEnabled
}
return nil
}
// Migration: v12 -> v13 (add payload file/data to faking config)
func migrateV12to13(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v12->v13: Adding TLSMod to faking config")
for _, set := range c.Sets {
set.Faking.TLSMod = DefaultSetConfig.Faking.TLSMod
}
return nil
}
// Migration: v11 -> v12 (add TCP FilterSYN setting)
func migrateV11to12(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v11->v12: Adding TCP FilterSYN setting")
for _, set := range c.Sets {
set.Faking.PayloadFile = DefaultSetConfig.Faking.PayloadFile
set.Faking.PayloadData = DefaultSetConfig.Faking.PayloadData
set.Fragmentation.SeqOverlapPattern = DefaultSetConfig.Fragmentation.SeqOverlapPattern
set.Fragmentation.SeqOverlapBytes = DefaultSetConfig.Fragmentation.SeqOverlapBytes
}
return nil
}
// Migration: v10 -> v11 (add devices config to queue)
func migrateV10to11(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v10->v11: Adding devices config to queue")
c.Queue.Devices = DefaultConfig.Queue.Devices
return nil
}
// Migration: v9 -> v10 (add error log file setting)
func migrateV9to10(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v9->v10: Adding error log file setting")
c.Queue.Interfaces = []string{}
return nil
}
// Migration: v8 -> v9
func migrateV8to9(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v8->v9: No changes, placeholder migration")
c.System.Logging.Directory = DefaultConfig.System.Logging.Directory
return nil
}
// Migration: v7 -> v8 (add DNS redirect settings)
func migrateV7to8(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v7->v8: Adding DNS redirect settings")
for _, set := range c.Sets {
set.DNS = DefaultSetConfig.DNS
}
c.System.Checker.ReferenceDNS = DefaultConfig.System.Checker.ReferenceDNS
return nil
}
// Migration: v6 -> v7 (add TCP syn TTL and drop SACK settings)
func migrateV6to7(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v6->v7: Adding TCP syn TTL and drop SACK settings")
for _, set := range c.Sets {
set.TCP.SynTTL = DefaultSetConfig.TCP.SynTTL
}
return nil
}
// Migration: v5 -> v6 (add reference domain to discovery config)
func migrateV5to6(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v5->v6: Initializing missing fields with default values")
for _, set := range c.Sets {
set.Fragmentation.Combo = DefaultSetConfig.Fragmentation.Combo
set.Fragmentation.Disorder = DefaultSetConfig.Fragmentation.Disorder
// set.Fragmentation.Overlap = DefaultSetConfig.Fragmentation.Overlap
}
return nil
}
// Migration: v4 -> v5 (add reference domain to discovery config)
func migrateV4to5(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v4->v5: Adding reference domain to discovery config")
c.System.Checker.ReferenceDomain = "max.ru"
return nil
}
// Migration: v0 -> v1 (add enabled field to sets)
func migrateV0to1(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v0->v1: Adding 'enabled' field to all sets")
for _, set := range c.Sets {
set.Enabled = true
}
return nil
}
func migrateV1to2(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v1->v2: Renaming sni_reverse to reverse_order")
for _, set := range c.Sets {
set.Fragmentation.ReverseOrder = DefaultSetConfig.Fragmentation.ReverseOrder
set.Fragmentation.OOBChar = DefaultSetConfig.Fragmentation.OOBChar
set.Fragmentation.OOBPosition = DefaultSetConfig.Fragmentation.OOBPosition
set.Fragmentation.TLSRecordPosition = DefaultSetConfig.Fragmentation.TLSRecordPosition
}
return nil
}
func migrateV2to3(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v2->v3: Adding TCP desync/window settings and SNI mutation")
for _, set := range c.Sets {
// TCP desync settings
set.TCP.Desync = DefaultSetConfig.TCP.Desync
// TCP window manipulation
set.TCP.Win = DefaultSetConfig.TCP.Win
// SNI mutation
set.Faking.SNIMutation = DefaultSetConfig.Faking.SNIMutation
}
return nil
}
func migrateV3to4(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v3->v4: Initializing missing fields with default values")
c.System.Checker.ConfigPropagateMs = DefaultConfig.System.Checker.ConfigPropagateMs
c.System.Checker.DiscoveryTimeoutSec = DefaultConfig.System.Checker.DiscoveryTimeoutSec
return nil
}
// discoverConfigPath checks well-known locations for a config file.
func discoverConfigPath() string {
candidates := []string{
@ -794,9 +365,13 @@ func (c *Config) LoadWithMigration(path string) (bool, error) {
migrated = true
log.Infof("Config version %d is older than current version %d, migrating",
c.Version, CurrentConfigVersion)
backupConfigBeforeMigration(path, data, c.Version)
if err := c.applyMigrations(c.Version, rawJSON); err != nil {
return false, err
}
} else if c.Version > CurrentConfigVersion {
log.Warnf("Config version %d is newer than the version %d supported by this binary; unknown settings will be dropped on the next save",
c.Version, CurrentConfigVersion)
}
c.System.Geo.SanitizePaths(filepath.Dir(c.ConfigPath))
@ -808,6 +383,15 @@ func (c *Config) LoadWithMigration(path string) (bool, error) {
return migrated, nil
}
func backupConfigBeforeMigration(path string, data []byte, fromVersion int) {
backupPath := fmt.Sprintf("%s.v%d.bak", path, fromVersion)
if err := os.WriteFile(backupPath, data, 0644); err != nil {
log.Warnf("Failed to back up config to %s before migration: %v", backupPath, err)
return
}
log.Infof("Backed up pre-migration config to %s", backupPath)
}
func (c *Config) migratePasswordHash() bool {
p := c.System.WebServer.Password
if p == "" || IsHashedPassword(p) {
@ -827,7 +411,8 @@ func (c *Config) applyMigrations(startVersion int, rawJSON map[string]interface{
for v := startVersion; v < CurrentConfigVersion; v++ {
migrationFunc, exists := migrationRegistry[v]
if !exists {
return fmt.Errorf("no migration path from version %d to %d", v, v+1)
c.Version = v + 1
continue
}
log.Infof("Applying migration: v%d -> v%d", v, v+1)

View file

@ -91,6 +91,15 @@ func TestLoadWithMigration(t *testing.T) {
if len(cfg.Sets) > 0 && !cfg.Sets[0].Enabled {
t.Error("migration should set Enabled=true")
}
backupPath := path + ".v0.bak"
backup, err := os.ReadFile(backupPath)
if err != nil {
t.Fatalf("expected pre-migration backup at %s: %v", backupPath, err)
}
if string(backup) != v0Json {
t.Error("backup should contain the original pre-migration config")
}
})
t.Run("current version skips migration", func(t *testing.T) {
@ -145,17 +154,14 @@ func TestDiscoverConfigPath(t *testing.T) {
}
func TestApplyMigrations(t *testing.T) {
t.Run("v0 to v1 sets enabled", func(t *testing.T) {
t.Run("versions without a registered migration are skipped", func(t *testing.T) {
cfg := NewConfig()
set := NewSetConfig()
set.Enabled = false
cfg.Sets = []*SetConfig{&set}
if err := cfg.applyMigrations(0, map[string]interface{}{}); err != nil {
t.Fatalf("migration failed: %v", err)
}
if !cfg.Sets[0].Enabled {
t.Error("v0->v1 should set Enabled=true")
if cfg.Version != CurrentConfigVersion {
t.Errorf("expected version %d, got %d", CurrentConfigVersion, cfg.Version)
}
})
@ -193,4 +199,49 @@ func TestApplyMigrations(t *testing.T) {
})
}
})
t.Run("v49 to v50 moves legacy mtproto secret into secrets list", func(t *testing.T) {
t.Run("legacy secret migrated from raw", func(t *testing.T) {
cfg := NewConfig()
cfg.System.MTProto.Secrets = nil
raw := map[string]interface{}{"system": map[string]interface{}{"mtproto": map[string]interface{}{"secret": "ee-legacy-secret"}}}
if err := migrateV49to50(&cfg, raw); err != nil {
t.Fatalf("migration failed: %v", err)
}
if len(cfg.System.MTProto.Secrets) != 1 {
t.Fatalf("want 1 migrated secret, got %d", len(cfg.System.MTProto.Secrets))
}
s := cfg.System.MTProto.Secrets[0]
if s.Secret != "ee-legacy-secret" {
t.Errorf("secret: want %q, got %q", "ee-legacy-secret", s.Secret)
}
if !s.Enabled {
t.Error("migrated secret should be enabled")
}
if s.ID == "" {
t.Error("migrated secret should get an ID")
}
})
t.Run("existing secrets are not overwritten", func(t *testing.T) {
cfg := NewConfig()
cfg.System.MTProto.Secrets = []MTProtoSecret{{ID: "keep", Secret: "ee-existing", Enabled: true}}
raw := map[string]interface{}{"system": map[string]interface{}{"mtproto": map[string]interface{}{"secret": "ee-legacy-secret"}}}
if err := migrateV49to50(&cfg, raw); err != nil {
t.Fatalf("migration failed: %v", err)
}
if len(cfg.System.MTProto.Secrets) != 1 || cfg.System.MTProto.Secrets[0].Secret != "ee-existing" {
t.Errorf("existing secrets should be untouched, got %+v", cfg.System.MTProto.Secrets)
}
})
t.Run("no legacy secret is a no-op", func(t *testing.T) {
cfg := NewConfig()
cfg.System.MTProto.Secrets = nil
if err := migrateV49to50(&cfg, map[string]interface{}{}); err != nil {
t.Fatalf("migration failed: %v", err)
}
if len(cfg.System.MTProto.Secrets) != 0 {
t.Errorf("want 0 secrets, got %d", len(cfg.System.MTProto.Secrets))
}
})
})
}

View file

@ -10,19 +10,13 @@ type MTProtoSecret struct {
}
func (m *MTProtoConfig) EffectiveSecrets() []MTProtoSecret {
if len(m.Secrets) > 0 {
out := make([]MTProtoSecret, 0, len(m.Secrets))
for _, s := range m.Secrets {
if s.Enabled && strings.TrimSpace(s.Secret) != "" {
out = append(out, s)
}
out := make([]MTProtoSecret, 0, len(m.Secrets))
for _, s := range m.Secrets {
if s.Enabled && strings.TrimSpace(s.Secret) != "" {
out = append(out, s)
}
return out
}
if strings.TrimSpace(m.Secret) != "" {
return []MTProtoSecret{{ID: "legacy", Secret: m.Secret, Enabled: true}}
}
return nil
return out
}
func (m *MTProtoConfig) FirstEnabledSecret() string {

View file

@ -272,20 +272,21 @@ type AIConfig struct {
}
type MTProtoConfig struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
BindAddress string `json:"bind_address"`
MaxConnections int `json:"max_connections"` // max concurrent client connections; 0 = default (2048)
Secret string `json:"secret"` // legacy single secret; mirrors the first enabled entry in Secrets for backward compatibility
Secrets []MTProtoSecret `json:"secrets,omitempty"`
FakeSNI string `json:"fake_sni"`
DCRelay string `json:"dc_relay"`
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
Enabled bool `json:"enabled"`
Port int `json:"port"`
BindAddress string `json:"bind_address"`
MaxConnections int `json:"max_connections"` // max concurrent client connections; 0 = default (2048)
TCPUserTimeoutSec int `json:"tcp_user_timeout_sec"`
IdleTimeoutSec int `json:"idle_timeout_sec"`
Secrets []MTProtoSecret `json:"secrets,omitempty"`
FakeSNI string `json:"fake_sni"`
DCRelay string `json:"dc_relay"`
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
DCFallbackEnabled bool `json:"dc_fallback_enabled"` // fetch the DC IP list from DCFallbackURL when Telegram's official endpoint is blocked
DCFallbackURL string `json:"dc_fallback_url"` // fallback source for the Telegram DC list; empty = built-in default

View file

@ -374,6 +374,16 @@ func (c *Config) checkPortCollisions(v *validator) {
map[string]any{"value": mc, "min": 0, "max": 100000},
"max_connections must be between 0 (default) and 100000 (got %d)", mc)
}
if ut := c.System.MTProto.TCPUserTimeoutSec; ut < -1 || ut > 86400 {
v.addf("system.mtproto.tcp_user_timeout_sec", "out_of_range",
map[string]any{"value": ut, "min": -1, "max": 86400},
"tcp_user_timeout_sec must be -1 (disable), 0 (default 120), or up to 86400 (got %d)", ut)
}
if it := c.System.MTProto.IdleTimeoutSec; it < -1 || it > 86400 {
v.addf("system.mtproto.idle_timeout_sec", "out_of_range",
map[string]any{"value": it, "min": -1, "max": 86400},
"idle_timeout_sec must be -1 (disable), 0 (default 300), or up to 86400 (got %d)", it)
}
}
for i := 0; i < len(refs); i++ {
for j := i + 1; j < len(refs); j++ {

View file

@ -5,8 +5,10 @@ import (
"io"
"net"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/log"
@ -19,6 +21,121 @@ func (api *API) RegisterMTProtoApi() {
api.mux.HandleFunc("/api/mtproto/config", api.handleMTProtoConfig)
api.mux.HandleFunc("/api/mtproto/refresh-dcs", api.handleMTProtoRefreshDCs)
api.mux.HandleFunc("/api/mtproto/test-ws", api.handleMTProtoTestWS)
api.mux.HandleFunc("/api/mtproto/sessions", api.handleMTProtoSessions)
api.mux.HandleFunc("/api/mtproto/active-clients", api.handleMTProtoActiveClients)
}
func mtprotoSessions() []mtproto.SessionInfo {
if p, ok := globalMTProtoServer.(interface {
Sessions() []mtproto.SessionInfo
}); ok {
return p.Sessions()
}
return nil
}
// @Summary List active MTProto sessions
// @Description One entry per live client connection, including client IP/port, upstream destination and activity timestamps.
// @Tags MTProto
// @Produce json
// @Success 200 {array} object
// @Security BearerAuth
// @Router /mtproto/sessions [get]
func (api *API) handleMTProtoSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
type sessionOut struct {
ID string `json:"id"`
Name string `json:"name"`
ClientIP string `json:"client_ip"`
ClientPort int `json:"client_port"`
Destination string `json:"destination"`
ConnectedAt string `json:"connected_at"`
LastSeen string `json:"last_seen"`
}
sessions := mtprotoSessions()
out := make([]sessionOut, 0, len(sessions))
for _, s := range sessions {
out = append(out, sessionOut{
ID: s.ID,
Name: s.Name,
ClientIP: s.ClientIP,
ClientPort: s.ClientPort,
Destination: s.Destination,
ConnectedAt: s.ConnectedAt.UTC().Format(time.RFC3339),
LastSeen: s.LastSeen.UTC().Format(time.RFC3339),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].ConnectedAt < out[j].ConnectedAt
})
sendResponse(w, out)
}
// @Summary List active MTProto clients per secret
// @Description Aggregated per-secret view: active connection count, the distinct client IPs currently using each secret, and last activity.
// @Tags MTProto
// @Produce json
// @Success 200 {array} object
// @Security BearerAuth
// @Router /mtproto/active-clients [get]
func (api *API) handleMTProtoActiveClients(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
type clientOut struct {
ID string `json:"id"`
Name string `json:"name"`
ActiveConnections int `json:"active_connections"`
ActiveIPs []string `json:"active_ips"`
ActiveIPCount int `json:"active_ip_count"`
LastSeen string `json:"last_seen"`
}
type agg struct {
out clientOut
ips []string
seenIP map[string]struct{}
lastSeen time.Time
}
order := make([]string, 0)
byID := make(map[string]*agg)
for _, s := range mtprotoSessions() {
a := byID[s.ID]
if a == nil {
a = &agg{
out: clientOut{ID: s.ID, Name: s.Name, ActiveIPs: []string{}},
seenIP: make(map[string]struct{}),
}
byID[s.ID] = a
order = append(order, s.ID)
}
a.out.ActiveConnections++
if s.ClientIP != "" {
if _, ok := a.seenIP[s.ClientIP]; !ok {
a.seenIP[s.ClientIP] = struct{}{}
a.ips = append(a.ips, s.ClientIP)
}
}
if s.LastSeen.After(a.lastSeen) {
a.lastSeen = s.LastSeen
}
}
out := make([]clientOut, 0, len(order))
for _, id := range order {
a := byID[id]
sort.Strings(a.ips)
a.out.ActiveIPs = a.ips
a.out.ActiveIPCount = len(a.ips)
a.out.LastSeen = a.lastSeen.UTC().Format(time.RFC3339)
out = append(out, a.out)
}
sendResponse(w, out)
}
// @Summary Probe MTProto upstream transports
@ -243,21 +360,7 @@ func (api *API) updateMTProtoConfig(w http.ResponseWriter, r *http.Request) {
}
}
req.Secret = strings.TrimSpace(req.Secret)
if req.Secret != "" {
if _, err := mtproto.ParseSecret(req.Secret); err != nil {
writeJsonError(w, http.StatusBadRequest, "Invalid secret: "+err.Error())
return
}
}
// Keep the legacy single-secret field mirroring the first enabled entry so
// older clients and the share-link path stay consistent.
if len(req.Secrets) > 0 {
req.Secret = req.FirstEnabledSecret()
}
hasSecret := req.Secret != "" || len(req.EffectiveSecrets()) > 0
hasSecret := len(req.EffectiveSecrets()) > 0
if req.Enabled && !hasSecret && req.FakeSNI == "" {
writeJsonError(w, http.StatusBadRequest, "At least one secret or a fake SNI domain is required when enabled")
return

View file

@ -0,0 +1,176 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/mtproto"
)
type stubMTProtoServer struct {
sessions []mtproto.SessionInfo
}
func (s *stubMTProtoServer) UpdateConfig(*config.Config) {}
func (s *stubMTProtoServer) Sessions() []mtproto.SessionInfo { return s.sessions }
func newMTProtoTestMux(t *testing.T, srv ConfigRefresher) *http.ServeMux {
t.Helper()
prev := globalMTProtoServer
globalMTProtoServer = srv
t.Cleanup(func() { globalMTProtoServer = prev })
cfg := config.NewConfig()
api := &API{cfgPtr: testCfgPtr(&cfg)}
mux := http.NewServeMux()
api.mux = mux
api.RegisterMTProtoApi()
return mux
}
func TestHandleMTProtoSessions(t *testing.T) {
t0 := time.Date(2026, 7, 2, 10, 0, 0, 0, time.UTC)
stub := &stubMTProtoServer{sessions: []mtproto.SessionInfo{
{
ID: "a", Name: "Max", ClientIP: "85.233.150.240", ClientPort: 42378,
Destination: "149.154.167.220:443", ConnectedAt: t0, LastSeen: t0.Add(15 * time.Second),
},
}}
mux := newMTProtoTestMux(t, stub)
req := httptest.NewRequest(http.MethodGet, "/api/mtproto/sessions", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var out []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("expected 1 session, got %d", len(out))
}
got := out[0]
want := map[string]any{
"id": "a", "name": "Max", "client_ip": "85.233.150.240",
"client_port": float64(42378), "destination": "149.154.167.220:443",
"connected_at": "2026-07-02T10:00:00Z", "last_seen": "2026-07-02T10:00:15Z",
}
for k, v := range want {
if got[k] != v {
t.Errorf("field %q = %v, want %v", k, got[k], v)
}
}
}
func TestHandleMTProtoSessionsMethodNotAllowed(t *testing.T) {
mux := newMTProtoTestMux(t, &stubMTProtoServer{})
req := httptest.NewRequest(http.MethodPost, "/api/mtproto/sessions", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", rec.Code)
}
}
func TestHandleMTProtoSessionsEmptyWhenNoServer(t *testing.T) {
prev := globalMTProtoServer
globalMTProtoServer = nil
t.Cleanup(func() { globalMTProtoServer = prev })
cfg := config.NewConfig()
api := &API{cfgPtr: testCfgPtr(&cfg)}
mux := http.NewServeMux()
api.mux = mux
api.RegisterMTProtoApi()
req := httptest.NewRequest(http.MethodGet, "/api/mtproto/sessions", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var out []map[string]any
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 0 {
t.Fatalf("expected empty array, got %d entries", len(out))
}
}
func TestHandleMTProtoActiveClients(t *testing.T) {
t0 := time.Date(2026, 7, 2, 10, 0, 0, 0, time.UTC)
stub := &stubMTProtoServer{sessions: []mtproto.SessionInfo{
{ID: "a", Name: "Max", ClientIP: "85.233.150.240", ClientPort: 42378, ConnectedAt: t0, LastSeen: t0.Add(15 * time.Second)},
{ID: "a", Name: "Max", ClientIP: "85.233.150.240", ClientPort: 42379, ConnectedAt: t0, LastSeen: t0.Add(18 * time.Second)},
{ID: "a", Name: "Max", ClientIP: "178.130.140.98", ClientPort: 55120, ConnectedAt: t0, LastSeen: t0.Add(17 * time.Second)},
{ID: "b", Name: "Ivan", ClientIP: "10.0.0.5", ClientPort: 5000, ConnectedAt: t0, LastSeen: t0.Add(1 * time.Second)},
}}
mux := newMTProtoTestMux(t, stub)
req := httptest.NewRequest(http.MethodGet, "/api/mtproto/active-clients", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
type clientOut struct {
ID string `json:"id"`
Name string `json:"name"`
ActiveConnections int `json:"active_connections"`
ActiveIPs []string `json:"active_ips"`
ActiveIPCount int `json:"active_ip_count"`
LastSeen string `json:"last_seen"`
}
var out []clientOut
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 clients, got %d", len(out))
}
byID := make(map[string]clientOut, len(out))
for _, c := range out {
byID[c.ID] = c
}
a, ok := byID["a"]
if !ok {
t.Fatalf("client a missing")
}
if a.ActiveConnections != 3 {
t.Errorf("active_connections = %d, want 3", a.ActiveConnections)
}
if a.ActiveIPCount != 2 {
t.Errorf("active_ip_count = %d, want 2", a.ActiveIPCount)
}
wantIPs := []string{"178.130.140.98", "85.233.150.240"}
if len(a.ActiveIPs) != len(wantIPs) {
t.Fatalf("active_ips = %v, want %v", a.ActiveIPs, wantIPs)
}
for i, ip := range wantIPs {
if a.ActiveIPs[i] != ip {
t.Errorf("active_ips[%d] = %q, want %q (expected sorted)", i, a.ActiveIPs[i], ip)
}
}
if a.LastSeen != "2026-07-02T10:00:18Z" {
t.Errorf("last_seen = %q, want the most recent activity 2026-07-02T10:00:18Z", a.LastSeen)
}
if b := byID["b"]; b.ActiveConnections != 1 || b.ActiveIPCount != 1 {
t.Errorf("client b unexpected: conns=%d ips=%d", b.ActiveConnections, b.ActiveIPCount)
}
}

View file

@ -258,6 +258,24 @@ export const MTProtoSettings = ({ config, onChange }: MTProtoSettingsProps) => {
disabled={!config.system.mtproto?.enabled}
helperText={t("settings.MTProto.maxConnectionsHelp")}
/>
<B4NumberField
label={t("settings.MTProto.tcpUserTimeout")}
value={config.system.mtproto?.tcp_user_timeout_sec ?? 120}
onChange={(n) => onChange("system.mtproto.tcp_user_timeout_sec", n)}
min={-1}
max={86400}
disabled={!config.system.mtproto?.enabled}
helperText={t("settings.MTProto.tcpUserTimeoutHelp")}
/>
<B4NumberField
label={t("settings.MTProto.idleTimeout")}
value={config.system.mtproto?.idle_timeout_sec ?? 300}
onChange={(n) => onChange("system.mtproto.idle_timeout_sec", n)}
min={-1}
max={86400}
disabled={!config.system.mtproto?.enabled}
helperText={t("settings.MTProto.idleTimeoutHelp")}
/>
<B4TextField
label={t("settings.MTProto.fakeSNI")}
value={config.system.mtproto?.fake_sni || "storage.googleapis.com"}

View file

@ -32,14 +32,8 @@ const newId = () =>
? crypto.randomUUID()
: `s-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const currentSecrets = (config: B4Config): MTProtoSecret[] => {
const mt = config.system.mtproto;
if (mt?.secrets?.length) return mt.secrets;
if (mt?.secret) {
return [{ id: "legacy", name: "", secret: mt.secret, enabled: true }];
}
return [];
};
const currentSecrets = (config: B4Config): MTProtoSecret[] =>
config.system.mtproto?.secrets ?? [];
export const MTProtoSecrets = ({
config,
@ -55,13 +49,10 @@ export const MTProtoSecrets = ({
const commit = (next: MTProtoSecret[]) => {
onChange("system.mtproto.secrets", next);
const firstEnabled = next.find((s) => s.enabled && s.secret) ?? next[0];
onChange("system.mtproto.secret", firstEnabled?.secret ?? "");
};
const update = (idx: number, patch: Partial<MTProtoSecret>) => {
const next = secrets.map((s, i) => (i === idx ? { ...s, ...patch } : s));
if (next[idx].id === "legacy") next[idx].id = newId();
commit(next);
};

View file

@ -264,6 +264,10 @@
"portHelp": "MTProto listen port (default: 3128)",
"maxConnections": "Max Connections",
"maxConnectionsHelp": "Maximum concurrent client connections (default: 2048). Each Telegram client uses several connections for chats and media; raise this for a shared proxy with many users.",
"tcpUserTimeout": "TCP User Timeout (sec)",
"tcpUserTimeoutHelp": "Force-close a connection after this many seconds of unacknowledged data, so a phone that dropped off mobile network is detected in ~2 min instead of ~15 min (default: 120, -1 to disable).",
"idleTimeout": "Idle Timeout (sec)",
"idleTimeoutHelp": "Close a relayed session after this many seconds with no traffic in either direction, freeing sessions held by a backgrounded client (default: 300, -1 to disable).",
"fakeSNI": "Fake SNI Domain",
"fakeSNIHelp": "Domain to impersonate in TLS handshake (e.g. storage.googleapis.com)",
"cfWorkerDomain": "Cloudflare Worker domain",

View file

@ -260,6 +260,10 @@
"portHelp": "Порт MTProto (по умолчанию: 3128)",
"maxConnections": "Макс. подключений",
"maxConnectionsHelp": "Максимум одновременных клиентских подключений (по умолчанию: 2048). Каждый клиент Telegram использует несколько подключений для чатов и медиа; увеличьте для общего прокси со множеством пользователей.",
"tcpUserTimeout": "TCP User Timeout (сек)",
"tcpUserTimeoutHelp": "Принудительно закрывать соединение после указанного числа секунд неподтверждённых данных, чтобы телефон, отвалившийся от мобильной сети, определялся за ~2 мин вместо ~15 мин (по умолчанию: 120, -1 - отключить).",
"idleTimeout": "Тайм-аут простоя (сек)",
"idleTimeoutHelp": "Закрывать релейную сессию после указанного числа секунд без трафика в обе стороны, освобождая сессии, удерживаемые свёрнутым клиентом (по умолчанию: 300, -1 - отключить).",
"fakeSNI": "Домен для Fake SNI",
"fakeSNIHelp": "Домен для имитации в TLS (например storage.googleapis.com)",
"cfWorkerDomain": "Домен Cloudflare Worker",

View file

@ -319,7 +319,8 @@ export interface MTProtoConfig {
port: number;
bind_address: string;
max_connections: number;
secret: string;
tcp_user_timeout_sec: number;
idle_timeout_sec: number;
secrets?: MTProtoSecret[];
fake_sni: string;
dc_relay: string;

View file

@ -0,0 +1,147 @@
package mtproto
import (
"net"
"sync"
"testing"
"time"
"github.com/daniellavrushin/b4/config"
"golang.org/x/sys/unix"
)
func TestSetTCPUserTimeoutAppliesToSocket(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
setTCPUserTimeout(c, 45*time.Second)
raw, err := c.(*net.TCPConn).SyscallConn()
if err != nil {
t.Fatal(err)
}
var got int
var gerr error
if cerr := raw.Control(func(fd uintptr) {
got, gerr = unix.GetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT)
}); cerr != nil {
t.Fatal(cerr)
}
if gerr != nil {
t.Fatal(gerr)
}
if got != 45000 {
t.Fatalf("TCP_USER_TIMEOUT = %d ms, want 45000", got)
}
}
func TestMTProtoTimeoutResolvers(t *testing.T) {
cases := []struct {
name string
userSet int
userTO time.Duration
idleSet int
idleTO time.Duration
}{
{"omitted -> defaults", 0, defaultUserTimeout, 0, defaultIdleTimeout},
{"disabled", -1, 0, -1, 0},
{"explicit", 45, 45 * time.Second, 90, 90 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.System.MTProto.TCPUserTimeoutSec = tc.userSet
cfg.System.MTProto.IdleTimeoutSec = tc.idleSet
if got := mtprotoTCPUserTimeout(cfg); got != tc.userTO {
t.Fatalf("user timeout(%d) = %v, want %v", tc.userSet, got, tc.userTO)
}
if got := mtprotoIdleTimeout(cfg); got != tc.idleTO {
t.Fatalf("idle timeout(%d) = %v, want %v", tc.idleSet, got, tc.idleTO)
}
})
}
}
func testRelayPool() *sync.Pool {
return &sync.Pool{New: func() interface{} {
b := make([]byte, relayBufSize)
return &b
}}
}
func TestRelayIdleReaperClosesSilentSession(t *testing.T) {
clientA, clientB := net.Pipe()
dcA, dcB := net.Pipe()
defer clientB.Close()
defer dcB.Close()
pool := testRelayPool()
done := make(chan struct{})
start := time.Now()
go func() {
relayConns(clientA, dcA, nil, "idle-test", pool, 300*time.Millisecond, nil)
close(done)
}()
select {
case <-done:
if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
t.Fatalf("relay returned after %v, too early to be the idle reaper", elapsed)
}
case <-time.After(3 * time.Second):
t.Fatal("relay was not reaped on idle: relayConns still blocked")
}
}
func TestRelayIdleReaperDisabled(t *testing.T) {
clientA, clientB := net.Pipe()
dcA, dcB := net.Pipe()
defer clientB.Close()
defer dcB.Close()
pool := testRelayPool()
done := make(chan struct{})
go func() {
relayConns(clientA, dcA, nil, "no-idle-test", pool, 0, nil)
close(done)
}()
select {
case <-done:
t.Fatal("relay returned though idle timeout disabled and no side closed")
case <-time.After(500 * time.Millisecond):
}
_ = clientA.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("relay did not return after the client conn was closed")
}
}
func TestWSTryWriteControlSkipsWhenWriterWedged(t *testing.T) {
c := &wsConn{}
c.wMu.Lock()
done := make(chan struct{})
go func() {
c.tryWriteControl(wsOpcodePong, nil)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("tryWriteControl blocked while the write lock was held by a wedged writer")
}
c.wMu.Unlock()
}

View file

@ -240,6 +240,8 @@ func proxyToMaskingDomain(client net.Conn, initial []byte, host string, mark uin
return
}
defer upstream.Close()
setTCPUserTimeout(upstream, defaultUserTimeout)
setTCPUserTimeout(client, defaultUserTimeout)
if len(initial) > 0 {
if _, err := upstream.Write(initial); err != nil {

View file

@ -521,6 +521,7 @@ func dialOne(p transportPlan, mark uint) (net.Conn, error) {
}
if tc, ok := conn.(*net.TCPConn); ok {
_ = tc.SetNoDelay(true)
setTCPUserTimeout(tc, defaultUserTimeout)
}
return conn, nil
}

View file

@ -20,6 +20,7 @@ import (
const (
defaultMaxConnections = 2048
relayBufSize = 65536
defaultIdleTimeout = 300 * time.Second
)
func mtprotoMaxConnections(cfg *config.Config) int {
@ -29,6 +30,28 @@ func mtprotoMaxConnections(cfg *config.Config) int {
return defaultMaxConnections
}
func mtprotoTCPUserTimeout(cfg *config.Config) time.Duration {
switch n := cfg.System.MTProto.TCPUserTimeoutSec; {
case n < 0:
return 0
case n == 0:
return defaultUserTimeout
default:
return time.Duration(n) * time.Second
}
}
func mtprotoIdleTimeout(cfg *config.Config) time.Duration {
switch n := cfg.System.MTProto.IdleTimeoutSec; {
case n < 0:
return 0
case n == 0:
return defaultIdleTimeout
default:
return time.Duration(n) * time.Second
}
}
type Server struct {
bufPool sync.Pool
active atomic.Int64
@ -93,9 +116,29 @@ func (s *Server) secretStat(sec *Secret) *secretStat {
return st
}
type connInfo struct {
secretID string
secretName string
clientIP string
clientPort int
connectedAt time.Time
dest atomic.Pointer[string]
lastActive atomic.Int64
}
type secretConnSet struct {
label string
conns map[net.Conn]struct{}
conns map[net.Conn]*connInfo
}
type SessionInfo struct {
ID string
Name string
ClientIP string
ClientPort int
Destination string
ConnectedAt time.Time
LastSeen time.Time
}
func secretIdentity(sec *Secret) string {
@ -106,20 +149,33 @@ func secretIdentity(sec *Secret) string {
return key + "|" + sec.Hex()
}
func (s *Server) trackConn(sec *Secret, c net.Conn) func() {
func (s *Server) trackConn(sec *Secret, c net.Conn) (*connInfo, func()) {
id := secretIdentity(sec)
info := &connInfo{
secretID: sec.ID,
secretName: sec.Label(),
connectedAt: time.Now(),
}
if host, port, err := net.SplitHostPort(c.RemoteAddr().String()); err == nil {
info.clientIP = host
if p, err := strconv.Atoi(port); err == nil {
info.clientPort = p
}
}
info.lastActive.Store(info.connectedAt.UnixNano())
s.connsMu.Lock()
if s.conns == nil {
s.conns = make(map[string]*secretConnSet)
}
set := s.conns[id]
if set == nil {
set = &secretConnSet{label: sec.Label(), conns: make(map[net.Conn]struct{})}
set = &secretConnSet{label: sec.Label(), conns: make(map[net.Conn]*connInfo)}
s.conns[id] = set
}
set.conns[c] = struct{}{}
set.conns[c] = info
s.connsMu.Unlock()
return func() {
return info, func() {
s.connsMu.Lock()
if set := s.conns[id]; set != nil {
delete(set.conns, c)
@ -131,6 +187,29 @@ func (s *Server) trackConn(sec *Secret, c net.Conn) func() {
}
}
func (s *Server) Sessions() []SessionInfo {
s.connsMu.Lock()
defer s.connsMu.Unlock()
out := make([]SessionInfo, 0, len(s.conns))
for _, set := range s.conns {
for _, info := range set.conns {
si := SessionInfo{
ID: info.secretID,
Name: info.secretName,
ClientIP: info.clientIP,
ClientPort: info.clientPort,
ConnectedAt: info.connectedAt,
LastSeen: time.Unix(0, info.lastActive.Load()),
}
if d := info.dest.Load(); d != nil {
si.Destination = *d
}
out = append(out, si)
}
}
return out
}
func (s *Server) secretActive(sec *Secret) bool {
ptr := s.secrets.Load()
if ptr == nil {
@ -292,7 +371,7 @@ func buildSecrets(cfg *config.Config) ([]*Secret, error) {
return nil, fmt.Errorf("MTProto: %d configured secret(s), none valid", invalid)
}
if len(mtCfg.Secrets) > 0 || strings.TrimSpace(mtCfg.Secret) != "" {
if len(mtCfg.Secrets) > 0 {
return nil, nil
}
@ -307,7 +386,6 @@ func buildSecrets(cfg *config.Config) ([]*Secret, error) {
sec.ID = entry.ID
sec.Name = entry.Name
mtCfg.Secrets = append(mtCfg.Secrets, entry)
mtCfg.Secret = sec.Hex()
if cfg.ConfigPath != "" {
if err := cfg.SaveToFile(cfg.ConfigPath); err != nil {
log.Warnf("MTProto: failed to persist generated secret: %v", err)
@ -450,7 +528,7 @@ func mtprotoNeedsRestart(old, newCfg *config.Config) bool {
}
func mtprotoSecretsChanged(o, n config.MTProtoConfig) bool {
if o.Secret != n.Secret || len(o.Secrets) != len(n.Secrets) {
if len(o.Secrets) != len(n.Secrets) {
return true
}
for i := range o.Secrets {
@ -505,6 +583,7 @@ func (s *Server) acceptLoop(ln net.Listener) {
_ = tc.SetNoDelay(true)
_ = tc.SetReadBuffer(256 * 1024)
_ = tc.SetWriteBuffer(256 * 1024)
setTCPUserTimeout(tc, mtprotoTCPUserTimeout(s.cfg.Load()))
}
go func(c net.Conn) {
@ -552,7 +631,7 @@ func (s *Server) handleConn(raw net.Conn) {
user := secret.Label()
log.Debugf("%s proxy fake-TLS handshake OK from %s (secret=%s)", tag, clientAddr, user)
untrack := s.trackConn(secret, raw)
info, untrack := s.trackConn(secret, raw)
defer untrack()
if !s.secretActive(secret) {
log.Infof("%s proxy secret %q revoked, dropping connection from %s", tag, user, clientAddr)
@ -584,6 +663,7 @@ func (s *Server) handleConn(raw net.Conn) {
if ra := dcConn.RemoteAddr(); ra != nil {
dcAddr = ra.String()
}
info.dest.Store(&dcAddr)
log.LogConnectionStr("TCP", "", secret.Host, clientAddr, "", dcAddr, "", "", mtprotoConnMeta(user))
st := s.secretStat(secret)
@ -595,16 +675,16 @@ func (s *Server) handleConn(raw net.Conn) {
if _, ok := dcConn.Conn.(*wsConn); ok {
splitter = newMsgSplitter(result.ProtoTag)
}
up, down := s.relay(result.Conn, dcConn, splitter, fmt.Sprintf("%s [%s] %s<->DC%d via %s", tag, user, clientAddr, result.DC, transport))
up, down := s.relay(result.Conn, dcConn, splitter, &info.lastActive, fmt.Sprintf("%s [%s] %s<->DC%d via %s", tag, user, clientAddr, result.DC, transport))
st.up.Add(up)
st.down.Add(down)
}
func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string) (up, down int64) {
return relayConns(client, dc, splitter, label, &s.bufPool)
func (s *Server) relay(client, dc io.ReadWriteCloser, splitter *msgSplitter, lastActive *atomic.Int64, label string) (up, down int64) {
return relayConns(client, dc, splitter, label, &s.bufPool, mtprotoIdleTimeout(s.cfg.Load()), lastActive)
}
func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string, bufPool *sync.Pool) (int64, int64) {
func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label string, bufPool *sync.Pool, idle time.Duration, lastActive *atomic.Int64) (int64, int64) {
type relayEnd struct {
dir string
err error
@ -612,6 +692,10 @@ func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label stri
endCh := make(chan relayEnd, 2)
start := time.Now()
var upBytes, downBytes atomic.Int64
if lastActive == nil {
lastActive = new(atomic.Int64)
}
lastActive.Store(start.UnixNano())
cp := func(dst io.Writer, src io.Reader, dir string, counter *atomic.Int64) {
bufPtr := bufPool.Get().(*[]byte)
@ -623,6 +707,7 @@ func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label stri
var n int
n, err = src.Read(buf)
if n > 0 {
lastActive.Store(time.Now().UnixNano())
if _, werr := dst.Write(buf[:n]); werr != nil {
err = werr
} else {
@ -648,6 +733,7 @@ func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label stri
var n int
n, err = src.Read(buf)
if n > 0 {
lastActive.Store(time.Now().UnixNano())
for _, pkt := range splitter.split(buf[:n]) {
if _, werr := dst.Write(pkt); werr != nil {
err = werr
@ -675,10 +761,39 @@ func relayConns(client, dc io.ReadWriteCloser, splitter *msgSplitter, label stri
}
go cp(client, dc, "DC->client", &downBytes)
done := make(chan struct{})
if idle > 0 {
go func() {
interval := idle / 4
if interval < 100*time.Millisecond {
interval = 100 * time.Millisecond
}
if interval > 15*time.Second {
interval = 15 * time.Second
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
if time.Since(time.Unix(0, lastActive.Load())) >= idle {
log.Infof("%s idle for %s, reaping", label, idle)
_ = client.Close()
_ = dc.Close()
return
}
}
}
}()
}
first := <-endCh
_ = client.Close()
_ = dc.Close()
<-endCh
close(done)
up, down := upBytes.Load(), downBytes.Load()
stale := ""

View file

@ -4,13 +4,15 @@ import (
"net"
"sync/atomic"
"testing"
"time"
"github.com/daniellavrushin/b4/config"
)
type closeRecordConn struct {
net.Conn
closed atomic.Bool
closed atomic.Bool
remoteAddr net.Addr
}
func (c *closeRecordConn) Close() error {
@ -18,6 +20,13 @@ func (c *closeRecordConn) Close() error {
return nil
}
func (c *closeRecordConn) RemoteAddr() net.Addr {
if c.remoteAddr != nil {
return c.remoteAddr
}
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345}
}
func mtprotoCfg(mut func(*config.MTProtoConfig)) *config.Config {
cfg := &config.Config{}
cfg.System.MTProto = config.MTProtoConfig{
@ -205,7 +214,7 @@ func TestUntrackRemovesConn(t *testing.T) {
srv, _, secA, _ := revocationTestServer(t)
connA := &closeRecordConn{}
untrack := srv.trackConn(secA, connA)
_, untrack := srv.trackConn(secA, connA)
untrack()
srv.closeRevokedConns(nil)
@ -214,6 +223,91 @@ func TestUntrackRemovesConn(t *testing.T) {
}
}
func TestSessionsReportsTrackedConns(t *testing.T) {
srv, _, secA, secB := revocationTestServer(t)
if s := srv.Sessions(); len(s) != 0 {
t.Fatalf("expected no sessions before any conn, got %d", len(s))
}
connA1 := &closeRecordConn{remoteAddr: &net.TCPAddr{IP: net.ParseIP("85.233.150.240"), Port: 42378}}
connA2 := &closeRecordConn{remoteAddr: &net.TCPAddr{IP: net.ParseIP("178.130.140.98"), Port: 55120}}
connB := &closeRecordConn{remoteAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5000}}
infoA1, _ := srv.trackConn(secA, connA1)
srv.trackConn(secA, connA2)
_, untrackB := srv.trackConn(secB, connB)
dest := "149.154.167.220:443"
infoA1.dest.Store(&dest)
sessions := srv.Sessions()
if len(sessions) != 3 {
t.Fatalf("expected 3 sessions, got %d", len(sessions))
}
byPort := make(map[int]SessionInfo, len(sessions))
for _, s := range sessions {
byPort[s.ClientPort] = s
}
a1, ok := byPort[42378]
if !ok {
t.Fatalf("session for client port 42378 missing")
}
if a1.ID != "a" || a1.Name != "Max" {
t.Fatalf("unexpected secret identity: id=%q name=%q", a1.ID, a1.Name)
}
if a1.ClientIP != "85.233.150.240" {
t.Fatalf("unexpected client ip: %q", a1.ClientIP)
}
if a1.Destination != dest {
t.Fatalf("destination = %q, want %q", a1.Destination, dest)
}
if a1.ConnectedAt.IsZero() {
t.Fatalf("connected_at not set")
}
if a1.LastSeen.Before(a1.ConnectedAt) {
t.Fatalf("last_seen %v before connected_at %v", a1.LastSeen, a1.ConnectedAt)
}
if a2, ok := byPort[55120]; !ok {
t.Fatalf("second connection for secret a missing")
} else if a2.Destination != "" {
t.Fatalf("expected empty destination before dial, got %q", a2.Destination)
}
untrackB()
sessions = srv.Sessions()
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions after untracking secret b, got %d", len(sessions))
}
for _, s := range sessions {
if s.ID == "b" {
t.Fatalf("untracked secret b still reported in sessions")
}
}
}
func TestSessionsLastSeenFollowsActivity(t *testing.T) {
srv, _, secA, _ := revocationTestServer(t)
connA := &closeRecordConn{}
infoA, _ := srv.trackConn(secA, connA)
before := srv.Sessions()[0].LastSeen
advanced := infoA.connectedAt.Add(30 * time.Second)
infoA.lastActive.Store(advanced.UnixNano())
got := srv.Sessions()[0].LastSeen
if !got.After(before) {
t.Fatalf("last_seen did not advance: before=%v after=%v", before, got)
}
if !got.Equal(advanced) {
t.Fatalf("last_seen = %v, want %v", got, advanced)
}
}
func TestBuildSecretsPolicy(t *testing.T) {
gen, err := GenerateSecret("x.example.com")
if err != nil {
@ -248,11 +342,6 @@ func TestBuildSecretsPolicy(t *testing.T) {
}},
0, true, 1,
},
{
"invalid legacy secret is an error, nothing generated",
config.MTProtoConfig{FakeSNI: "s.example.com", Secret: "junk"},
0, true, 0,
},
{
"nothing configured with fake sni generates one",
config.MTProtoConfig{FakeSNI: "s.example.com"},

28
src/mtproto/sockopt.go Normal file
View file

@ -0,0 +1,28 @@
package mtproto
import (
"net"
"time"
"golang.org/x/sys/unix"
)
const defaultUserTimeout = 120 * time.Second
func setTCPUserTimeout(c net.Conn, d time.Duration) {
tc, ok := c.(*net.TCPConn)
if !ok || d <= 0 {
return
}
ms := int(d.Milliseconds())
if ms <= 0 {
return
}
raw, err := tc.SyscallConn()
if err != nil {
return
}
_ = raw.Control(func(fd uintptr) {
_ = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, ms)
})
}

View file

@ -165,7 +165,7 @@ func (b *TransparentBridge) Handle(client net.Conn, origIP net.IP, origPort int)
if _, isWS := dcConn.Conn.(*wsConn); isWS {
splitter = newMsgSplitter(res.ProtoTag)
}
relayConns(res.Conn, dcConn, splitter, label, &b.bufPool)
relayConns(res.Conn, dcConn, splitter, label, &b.bufPool, mtprotoIdleTimeout(cfg), nil)
return true, nil
}
@ -194,7 +194,7 @@ func (b *TransparentBridge) FailOpenViaWorker(client net.Conn, origIP net.IP, or
}
log.Infof("%s failopen relay %s:%d via wsworker://%s", tag, dst, origPort, wd)
label := fmt.Sprintf("%s %s<->%s:%d(failopen)", tag, client.RemoteAddr(), dst, origPort)
relayConns(client, wc, nil, label, &b.bufPool)
relayConns(client, wc, nil, label, &b.bufPool, mtprotoIdleTimeout(cfg), nil)
return true
}
return false

View file

@ -99,13 +99,11 @@ func (c *wsConn) Read(p []byte) (int, error) {
return n, nil
}
case wsOpcodePing:
if err := c.writeFrame(wsOpcodePong, payload); err != nil {
return 0, err
}
c.tryWriteControl(wsOpcodePong, payload)
case wsOpcodePong:
case wsOpcodeClose:
c.tryWriteControl(wsOpcodeClose, nil)
c.closed.Store(true)
_ = c.writeFrame(wsOpcodeClose, nil)
return 0, io.EOF
default:
return 0, fmt.Errorf("ws: unsupported opcode 0x%x", op)
@ -125,7 +123,8 @@ func (c *wsConn) Write(p []byte) (int, error) {
func (c *wsConn) Close() error {
if !c.closed.Swap(true) {
_ = c.writeFrame(wsOpcodeClose, nil)
c.tryWriteControl(wsOpcodeClose, nil)
_ = c.tls.SetWriteDeadline(time.Now())
}
return c.tls.Close()
}
@ -235,7 +234,7 @@ func (c *wsConn) readFrame() (op byte, fin bool, payload []byte, err error) {
return op, fin, payload, nil
}
func (c *wsConn) writeFrame(op byte, payload []byte) error {
func buildWSFrame(op byte, payload []byte) ([]byte, error) {
var hdr [14]byte
hdr[0] = 0x80 | op
n := len(payload)
@ -254,21 +253,49 @@ func (c *wsConn) writeFrame(op byte, payload []byte) error {
off = 10
}
if _, err := rand.Read(hdr[off : off+4]); err != nil {
return err
return nil, err
}
off += 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]
}
return buf, nil
}
func (c *wsConn) writeFrame(op byte, payload []byte) error {
buf, err := buildWSFrame(op, payload)
if err != nil {
return err
}
c.wMu.Lock()
defer c.wMu.Unlock()
_, err := c.tls.Write(buf)
return err
_, werr := c.tls.Write(buf)
return werr
}
func (c *wsConn) writeFrameLocked(op byte, payload []byte) error {
buf, err := buildWSFrame(op, payload)
if err != nil {
return err
}
_, werr := c.tls.Write(buf)
return werr
}
func (c *wsConn) tryWriteControl(op byte, payload []byte) {
if !c.wMu.TryLock() {
return
}
defer c.wMu.Unlock()
_ = c.tls.SetWriteDeadline(time.Now().Add(time.Second))
_ = c.writeFrameLocked(op, payload)
if !c.closed.Load() {
_ = c.tls.SetWriteDeadline(time.Time{})
}
}
func dialWS(host, sni, path string, timeout time.Duration, mark uint) (net.Conn, error) {
@ -297,6 +324,7 @@ func dialWS(host, sni, path string, timeout time.Duration, mark uint) (net.Conn,
// ~16KB send) limits BDP for big media transfers from EU TG edge
_ = tc.SetReadBuffer(256 * 1024)
_ = tc.SetWriteBuffer(256 * 1024)
setTCPUserTimeout(tc, defaultUserTimeout)
}
// 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.

View file

@ -22,6 +22,26 @@ func markedDialer(timeout time.Duration, bypassMark uint32) net.Dialer {
return d
}
const failOpenUserTimeout = 120 * time.Second
func setTCPUserTimeout(c net.Conn, d time.Duration) {
tc, ok := c.(*net.TCPConn)
if !ok || d <= 0 {
return
}
ms := int(d.Milliseconds())
if ms <= 0 {
return
}
raw, err := tc.SyscallConn()
if err != nil {
return
}
_ = raw.Control(func(fd uintptr) {
_ = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, ms)
})
}
type DomainResolver interface {
DomainFor(ip net.IP) string
}
@ -263,6 +283,8 @@ func (l *Listener) failOpenDirect(client net.Conn, origIP net.IP, origPort int)
return
}
defer direct.Close()
setTCPUserTimeout(client, failOpenUserTimeout)
setTCPUserTimeout(direct, failOpenUserTimeout)
pipe(client, direct)
}