mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
feat: implement tunable MTProto connection timeouts and improve sessi… (#269)
This commit is contained in:
parent
463809e12c
commit
dcc0fffa16
27 changed files with 6812 additions and 546 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-кодом, поэтому доступ можно выдавать или отзывать по одному человеку.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
docs/static/swagger-versions/index.json
vendored
2
docs/static/swagger-versions/index.json
vendored
|
|
@ -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
5695
docs/static/swagger-versions/v1.73.0.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
132
docs/static/swagger.json
vendored
132
docs/static/swagger.json
vendored
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++ {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
176
src/http/handler/mtproto_test.go
Normal file
176
src/http/handler/mtproto_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
147
src/mtproto/deadconn_test.go
Normal file
147
src/mtproto/deadconn_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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
28
src/mtproto/sockopt.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue