From 388784eb5e0c18a1e0ddc3cb53591d715d9cdcdd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 11 Apr 2026 16:12:45 +0700 Subject: [PATCH] Enhance MTProxy checker by introducing a new `-probe` mode `deep-strict` for stricter response validation. Update `README` and Docker documentation to clarify probing modes, exit codes, and the new `MTPROXY_TDLIB_HELPER` and `MTPROXY_TDLIB_TIMEOUT` environment variables for external helper integration. Refactor connection handling and error reporting to improve robustness and clarity in response verification. --- README.md | 6 +- cmd/mtproxy_checker/main.go | 2 +- cmd/mtproxy_checkerd/main.go | 56 ++++- cmd/mtproxy_checkerd/tdlib_block.go | 33 +++ cmd/tdlib_ping/README.md | 51 +++++ cmd/tdlib_ping/stub.go | 19 ++ cmd/tdlib_ping/tdlib.go | 292 +++++++++++++++++++++++++++ contrib/tdlib-ping/README.md | 40 ++++ contrib/tdlib-ping/package.json | 15 ++ contrib/tdlib-ping/ping.js | 153 ++++++++++++++ docs/docker.ru.md | 13 +- internal/checker/checker.go | 55 ++++- internal/checker/options.go | 8 +- internal/tdlibexec/tdlibexec.go | 112 ++++++++++ internal/tdlibexec/tdlibexec_test.go | 17 ++ internal/tgquick/probe.go | 42 +++- internal/tgquick/probe_test.go | 12 ++ 17 files changed, 902 insertions(+), 24 deletions(-) create mode 100644 cmd/mtproxy_checkerd/tdlib_block.go create mode 100644 cmd/tdlib_ping/README.md create mode 100644 cmd/tdlib_ping/stub.go create mode 100644 cmd/tdlib_ping/tdlib.go create mode 100644 contrib/tdlib-ping/README.md create mode 100644 contrib/tdlib-ping/package.json create mode 100644 contrib/tdlib-ping/ping.js create mode 100644 internal/tdlibexec/tdlibexec.go create mode 100644 internal/tdlibexec/tdlibexec_test.go diff --git a/README.md b/README.md index d06e5cd..16e8ab6 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker .\mtproxy_checker.exe --server HOST --port PORT --secret HEX ``` -Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-dc-ids` (например `1,2,3,4,5` — проверка **каждого** DC по очереди; `-timeout` на **один** DC; общий лимит времени умножается на число DC; код `0`, если **хотя бы один** DC прошёл; список удавшихся DC печатается в stderr), `-probe fast|deep` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — `req_pq`/`resPQ` до DC). +Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-dc-ids` (например `1,2,3,4,5` — проверка **каждого** DC по очереди; `-timeout` на **один** DC; общий лимит времени умножается на число DC; код `0`, если **хотя бы один** DC прошёл; список удавшихся DC печатается в stderr), `-probe fast|deep|deep-strict` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — после init отправляется `req_pq`, успех если от DC пришло **любое** валидное unencrypted MTProto-сообщение (часто `resPQ`, но не только); **`deep-strict`** — как раньше `deep`, ответ должен быть именно `resPQ`). -Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс `resPQ` от DC); при **нескольких** DC — `0`, если любой из них OK. `1` — ошибка (в **`deep`** в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. +Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс ответ DC после `req_pq`); при **нескольких** DC — `0`, если любой из них OK. `1` — ошибка (в **`deep`** в т.ч. нет ответа DC в ожидаемом виде; в **`deep-strict`** — нет `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки (в **`fast`** в т.ч. отложенный FIN/RST после init — ловится дополнительным коротким чтением после окна Telethon), `4` — таймаут. **Docker:** [docs/docker.ru.md](docs/docker.ru.md) — один образ: **с аргументами** после образа — CLI; **без аргументов** — HTTP API (файл со списком `tg://`, интервал, опциональный whitelist IP). +**HTTP API и TDLib:** в JSON каждой строки прокси поля **`standard`** (наш TCP/MTProxy чек) и **`tdlib`** (опционально: внешний helper TDLib `addProxy`+`pingProxy`). Рекомендуется нативный бинарник **`tdlib_ping`** (`cmd/tdlib_ping`, официальный JSON API TDLib через `libtdjson`). Альтернатива на Node — `contrib/tdlib-ping`. Задаётся `MTPROXY_TDLIB_HELPER` и `MTPROXY_TDLIB_TIMEOUT`; helper вызывается только в режиме **`fast`**. Базовый Docker-образ остаётся без TDLib/Node — helper подключают своим слоем образа или volume. + ### Если в Telegram прокси «Available», а утилита падает на `read server hello` Для `ee` в TLS SNI должен идти **только ASCII hostname** из хвоста секрета; в коде это `SNIDomain`. Ответ сервера читается по схеме из Telegram Desktop (`mtproto_tls_socket`), а не по старому фиксированному формату telethon. diff --git a/cmd/mtproxy_checker/main.go b/cmd/mtproxy_checker/main.go index 5f0c7b2..4eb5e73 100644 --- a/cmd/mtproxy_checker/main.go +++ b/cmd/mtproxy_checker/main.go @@ -22,7 +22,7 @@ func main() { func run() int { timeout := flag.Duration("timeout", 15*time.Second, "overall TCP/handshake timeout") - probe := flag.String("probe", "fast", "fast: handshake+init, Telethon-style post-init wait (no immediate close); deep: req_pq/resPQ via DC") + probe := flag.String("probe", "fast", "fast: handshake+init, Telethon-style post-init wait (no immediate close); deep: req_pq + any unencrypted DC reply; deep-strict: must be resPQ") dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header (ignored if -dc-ids is set)") dcIDsFlag := flag.String("dc-ids", "", "comma-separated DC ids to probe in order (e.g. 1,2,3,4,5); OK if any succeeds; timeout is per-DC") server := flag.String("server", "", "proxy hostname (if not using tg:// positional)") diff --git a/cmd/mtproxy_checkerd/main.go b/cmd/mtproxy_checkerd/main.go index fe6dc48..b9eb8ec 100644 --- a/cmd/mtproxy_checkerd/main.go +++ b/cmd/mtproxy_checkerd/main.go @@ -39,6 +39,8 @@ type config struct { dcIDs []int16 probe checker.ProbeMode allowedPrefixes []netip.Prefix + tdlibHelper string + tdlibTimeout time.Duration } func loadConfig() (*config, error) { @@ -90,6 +92,15 @@ func loadConfig() (*config, error) { return nil, err } probe := checker.ParseProbe(os.Getenv("MTPROXY_PROBE")) + tdlibHelper := strings.TrimSpace(os.Getenv("MTPROXY_TDLIB_HELPER")) + tdlibTimeoutStr := strings.TrimSpace(os.Getenv("MTPROXY_TDLIB_TIMEOUT")) + if tdlibTimeoutStr == "" { + tdlibTimeoutStr = "45s" + } + tdlibTimeout, err := time.ParseDuration(tdlibTimeoutStr) + if err != nil || tdlibTimeout <= 0 { + return nil, fmt.Errorf("MTPROXY_TDLIB_TIMEOUT: invalid duration %q", tdlibTimeoutStr) + } return &config{ listFile: listFile, checkInterval: interval, @@ -98,6 +109,8 @@ func loadConfig() (*config, error) { dcIDs: dcIDs, probe: probe, allowedPrefixes: prefixes, + tdlibHelper: tdlibHelper, + tdlibTimeout: tdlibTimeout, }, nil } @@ -108,14 +121,32 @@ type dcProbeResult struct { Error string `json:"error,omitempty"` } +type standardProbe struct { + OK bool `json:"ok"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` + ParseError string `json:"parse_error,omitempty"` +} + +type tdlibProbe struct { + SkippedReason string `json:"skipped_reason,omitempty"` + Ran bool `json:"ran"` + OK bool `json:"ok"` + ExitCode int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + type proxyEntry struct { RawLine string `json:"raw_line"` URL string `json:"url,omitempty"` + CheckedAt string `json:"checked_at,omitempty"` + Standard standardProbe `json:"standard"` + TDLib tdlibProbe `json:"tdlib"` OK bool `json:"ok"` ExitCode int `json:"exit_code"` Error string `json:"error,omitempty"` ParseError string `json:"parse_error,omitempty"` - CheckedAt string `json:"checked_at,omitempty"` DCs []dcProbeResult `json:"dcs,omitempty"` } @@ -185,9 +216,13 @@ func aggregateExitFromDCs(dcs []dcProbeResult) int { return 1 } -func checkOneLine(ctx context.Context, line string, dcIDs []int16, perDCTimeout time.Duration, probe checker.ProbeMode) proxyEntry { +func checkOneLine(ctx context.Context, line string, dcIDs []int16, perDCTimeout time.Duration, probe checker.ProbeMode, tdlibHelper string, tdlibTimeout time.Duration) (ent proxyEntry) { now := time.Now().UTC().Format(time.RFC3339) - ent := proxyEntry{RawLine: line, CheckedAt: now} + ent = proxyEntry{RawLine: line, CheckedAt: now} + defer func() { + ent.Standard = standardProbe{OK: ent.OK, ExitCode: ent.ExitCode, Error: ent.Error, ParseError: ent.ParseError} + ent.TDLib = runTDLibBlock(line, tdlibHelper, tdlibTimeout, probe, ent) + }() if len(dcIDs) == 0 { ent.ExitCode = 2 ent.Error = "no DC ids configured" @@ -210,8 +245,8 @@ func checkOneLine(ctx context.Context, line string, dcIDs []int16, perDCTimeout } if len(dcIDs) == 1 { dcCtx, cancel := context.WithTimeout(ctx, perDCTimeout) - defer cancel() err = checker.Check(dcCtx, t.Host, t.Port, parsed, dcIDs[0], &checker.Options{Probe: probe}) + cancel() code, msg := checkresult.Classify(err) ent.ExitCode = code ent.OK = err == nil @@ -262,6 +297,8 @@ func runCycle(cfg *config, st *store) { ParseError: err.Error(), Error: err.Error(), CheckedAt: finished.Format(time.RFC3339), + Standard: standardProbe{OK: false, ExitCode: 2, Error: err.Error(), ParseError: err.Error()}, + TDLib: tdlibProbe{SkippedReason: "cycle aborted (list file error)", Ran: false}, }}, finished, time.Now().Add(cfg.checkInterval)) log.Printf("read list file: %v", err) return @@ -272,8 +309,11 @@ func runCycle(cfg *config, st *store) { if len(cfg.dcIDs) > 1 { total = cfg.checkTimeout * time.Duration(len(cfg.dcIDs)) } + if cfg.probe == checker.ProbeFast && strings.TrimSpace(cfg.tdlibHelper) != "" { + total += cfg.tdlibTimeout + } ctx, cancel := context.WithTimeout(context.Background(), total) - ent := checkOneLine(ctx, line, cfg.dcIDs, cfg.checkTimeout, cfg.probe) + ent := checkOneLine(ctx, line, cfg.dcIDs, cfg.checkTimeout, cfg.probe, cfg.tdlibHelper, cfg.tdlibTimeout) cancel() entries = append(entries, ent) } @@ -353,7 +393,11 @@ func run() error { errCh := make(chan error, 1) go func() { - log.Printf("listening on %s, list=%s interval=%s dcs=%v", cfg.httpAddr, cfg.listFile, cfg.checkInterval, cfg.dcIDs) + if cfg.tdlibHelper != "" { + log.Printf("listening on %s, list=%s interval=%s dcs=%v tdlib_helper=%s tdlib_timeout=%s", cfg.httpAddr, cfg.listFile, cfg.checkInterval, cfg.dcIDs, cfg.tdlibHelper, cfg.tdlibTimeout) + } else { + log.Printf("listening on %s, list=%s interval=%s dcs=%v", cfg.httpAddr, cfg.listFile, cfg.checkInterval, cfg.dcIDs) + } errCh <- srv.ListenAndServe() }() diff --git a/cmd/mtproxy_checkerd/tdlib_block.go b/cmd/mtproxy_checkerd/tdlib_block.go new file mode 100644 index 0000000..fb99601 --- /dev/null +++ b/cmd/mtproxy_checkerd/tdlib_block.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "errors" + "strings" + "time" + + "mtproxy_checker/internal/checker" + "mtproxy_checker/internal/tdlibexec" +) + +func runTDLibBlock(proxyLine, helper string, timeout time.Duration, probe checker.ProbeMode, ent proxyEntry) tdlibProbe { + if probe != checker.ProbeFast { + return tdlibProbe{SkippedReason: "tdlib runs only with probe fast (or empty MTPROXY_PROBE)", Ran: false} + } + if ent.ParseError != "" { + return tdlibProbe{SkippedReason: "skipped: standard parse error", Ran: false} + } + if strings.TrimSpace(helper) == "" { + return tdlibProbe{SkippedReason: "MTPROXY_TDLIB_HELPER not set", Ran: false} + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + res, err := tdlibexec.Run(ctx, helper, proxyLine) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return tdlibProbe{Ran: true, OK: false, ExitCode: 4, Error: err.Error(), DurationMs: res.DurationMs} + } + return tdlibProbe{Ran: true, OK: false, ExitCode: 1, Error: err.Error(), DurationMs: res.DurationMs} + } + return tdlibProbe{Ran: true, OK: res.OK, ExitCode: res.ExitCode, Error: res.Error, DurationMs: res.DurationMs} +} diff --git a/cmd/tdlib_ping/README.md b/cmd/tdlib_ping/README.md new file mode 100644 index 0000000..c5d04eb --- /dev/null +++ b/cmd/tdlib_ping/README.md @@ -0,0 +1,51 @@ +# tdlib_ping — официальный JSON-интерфейс TDLib + +Бинарник вызывает **`td_create_client_id` / `td_send` / `td_receive` / `td_execute`** из [`td/telegram/td_json_client.h`](https://github.com/tdlib/td/blob/master/td/telegram/td_json_client.h) (рекомендуемый multi-client API), без Node.js. + +Контракт stdout — одна строка JSON для `mtproxy_checkerd`: + +`{"ok":true,"error":"","exit_code":0}` + +## Сборка + +Linux (Debian/Ubuntu), установите заголовки и библиотеку, например: + +```bash +sudo apt-get install -y libtdjson-dev # имя пакета может отличаться по дистрибутиву +export CGO_ENABLED=1 +go build -tags=tdlib -o tdlib_ping ./cmd/tdlib_ping +``` + +macOS: `brew install tdlib`, затем при необходимости `export PKG_CONFIG_PATH=...` если `pkg-config --libs tdlib` находит `-ltdjson`. + +Без `libtdjson` линковка завершится ошибкой — это ожидаемо. + +Сборка **без** тега (как часть `go build ./...`): + +```bash +go build ./cmd/tdlib_ping +``` + +получится заглушка, которая печатает JSON с `exit_code: 2` и пояснением — чтобы репозиторий собирался без TDLib. + +## Переменные окружения + +| Переменная | По умолчанию | Назначение | +|------------|--------------|------------| +| `MTPROXY_TD_API_ID` | `12345` | Замените на значение с [my.telegram.org](https://my.telegram.org) | +| `MTPROXY_TD_API_HASH` | демо-строка из примера TDLib | Замените на свой `api_hash` | +| `MTPROXY_TDLIB_PING_MS` | `45000` | Общий дедлайн цикла (мс) | +| `MTPROXY_TDLIB_DATABASE_DIR` | пусто | Если задан — постоянный каталог для `database_directory` / `files_directory` (подкаталоги `db/` и `files/` создаются автоматически). Иначе — временный каталог на каждый запуск | +| `MTPROXY_TD_USE_TEST_DC` | пусто | Если `1` — `use_test_dc: true` в `setTdlibParameters` (иногда помогает обойти ожидание телефона на «чистой» БД) | + +## Запуск + +```bash +./tdlib_ping 'tg://proxy?server=HOST&port=PORT&secret=HEX' +``` + +Если **`MTPROXY_TDLIB_DATABASE_DIR`** не задан, каталоги БД TDLib создаются под уникальным префиксом во временном каталоге на каждый запуск (и удаляются после выхода). + +## Ограничение + +При первом запуске с «чистой» БД TDLib может перейти в **`authorizationStateWaitPhoneNumber`**. Тогда helper завершится с ошибкой в JSON — нужен уже проинициализированный `database_directory` или рабочий сценарий входа. Для типичного мониторинга MTProxy на выделенной машине обычно достаточно повторных запусков с фиксированным каталогом (`MTPROXY_TDLIB_DATABASE_DIR`, см. таблицу выше). diff --git a/cmd/tdlib_ping/stub.go b/cmd/tdlib_ping/stub.go new file mode 100644 index 0000000..3fbee4b --- /dev/null +++ b/cmd/tdlib_ping/stub.go @@ -0,0 +1,19 @@ +//go:build !tdlib || !cgo + +package main + +import ( + "encoding/json" + "os" +) + +func main() { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(map[string]interface{}{ + "ok": false, + "error": "tdlib_ping: build with CGO and -tags=tdlib; requires libtdjson (see cmd/tdlib_ping/README.md)", + "exit_code": 2, + }) + os.Exit(2) +} diff --git a/cmd/tdlib_ping/tdlib.go b/cmd/tdlib_ping/tdlib.go new file mode 100644 index 0000000..38571dc --- /dev/null +++ b/cmd/tdlib_ping/tdlib.go @@ -0,0 +1,292 @@ +//go:build tdlib && cgo + +package main + +/* +#cgo LDFLAGS: -ltdjson +#cgo CFLAGS: -I/usr/include -I/usr/local/include +#include +#include +*/ +import "C" + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + "unsafe" + + "mtproxy_checker/internal/parseurl" + "mtproxy_checker/internal/secret" +) + +func main() { + if len(os.Args) < 2 { + outJSON(false, "usage: tdlib_ping ", 2) + } + run(os.Args[1]) +} + +func outJSON(ok bool, msg string, code int) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(map[string]interface{}{ + "ok": ok, "error": msg, "exit_code": code, + }) + if ok { + os.Exit(0) + } + switch code { + case 1: + os.Exit(1) + case 4: + os.Exit(4) + default: + os.Exit(2) + } +} + +func run(proxyURL string) { + deadlineMs, _ := strconv.Atoi(os.Getenv("MTPROXY_TDLIB_PING_MS")) + if deadlineMs <= 0 { + deadlineMs = 45000 + } + deadline := time.Now().Add(time.Duration(deadlineMs) * time.Millisecond) + + t, err := parseurl.ParseTGProxy(proxyURL) + if err != nil { + outJSON(false, err.Error(), 1) + } + parsed, err := secret.Parse(t.Secret) + if err != nil { + outJSON(false, err.Error(), 1) + } + secHex := mtprotoSecretHex(parsed) + + apiID := int64(12345) + if v := strings.TrimSpace(os.Getenv("MTPROXY_TD_API_ID")); v != "" { + if n, e := strconv.ParseInt(v, 10, 32); e == nil { + apiID = n + } + } + apiHash := strings.TrimSpace(os.Getenv("MTPROXY_TD_API_HASH")) + if apiHash == "" { + apiHash = "0123456789abcdef0123456789abcdef" + } + useTest := strings.TrimSpace(os.Getenv("MTPROXY_TD_USE_TEST_DC")) == "1" + + dbBase := strings.TrimSpace(os.Getenv("MTPROXY_TDLIB_DATABASE_DIR")) + var cleanupTemp string + if dbBase == "" { + cleanupTemp, err = os.MkdirTemp("", "mtproxy_tdlib_") + if err != nil { + outJSON(false, err.Error(), 2) + } + dbBase = cleanupTemp + defer func() { _ = os.RemoveAll(cleanupTemp) }() + } + + verb := `{"@type":"setLogVerbosityLevel","new_verbosity_level":1}` + if cs := C.CString(verb); cs != nil { + _ = C.td_execute(cs) + C.free(unsafe.Pointer(cs)) + } + + clientID := int(C.td_create_client_id()) + defer closeClient(clientID) + + if !waitAuthState(clientID, "authorizationStateWaitTdlibParameters", deadline) { + outJSON(false, "timeout waiting for authorizationStateWaitTdlibParameters", 4) + } + + params := map[string]interface{}{ + "@type": "setTdlibParameters", + "use_test_dc": useTest, + "database_directory": dbBase + "/db", + "files_directory": dbBase + "/files", + "use_file_database": true, + "use_chat_info_database": true, + "use_message_database": true, + "use_secret_chats": true, + "api_id": apiID, + "api_hash": apiHash, + "system_language_code": "en", + "device_model": "mtproxy-tdlib-ping", + "application_version": "1.0", + } + if err := os.MkdirAll(dbBase+"/db", 0o700); err != nil { + outJSON(false, err.Error(), 2) + } + if err := os.MkdirAll(dbBase+"/files", 0o700); err != nil { + outJSON(false, err.Error(), 2) + } + tdSend(clientID, params) + + for time.Now().Before(deadline) { + ev := tdReceive(1.0) + if ev == nil { + continue + } + if typ, _ := ev["@type"].(string); typ == "error" { + outJSON(false, fmt.Sprintf("tdlib: %v", ev["message"]), 2) + } + switch authStateType(ev) { + case "authorizationStateWaitEncryptionKey": + tdSend(clientID, map[string]interface{}{ + "@type": "checkDatabaseEncryptionKey", "encryption_key": "", + }) + case "authorizationStateReady": + goto doProxy + case "authorizationStateWaitPhoneNumber": + outJSON(false, "TDLib authorizationStateWaitPhoneNumber: use a persistent MTPROXY_TDLIB_DATABASE_DIR after TDLib login, or set MTPROXY_TD_USE_TEST_DC=1", 2) + case "authorizationStateClosed": + outJSON(false, "unexpected authorizationStateClosed before ping", 2) + } + } + outJSON(false, "timeout in TDLib authorization", 4) + +doProxy: + addBody := map[string]interface{}{ + "@type": "addProxy", + "server": t.Host, "port": t.Port, "enable": true, + "type": map[string]interface{}{ + "@type": "proxyTypeMtproto", "secret": secHex, + }, + "@extra": "addproxy", + } + tdSend(clientID, addBody) + + var proxyID int64 + for time.Now().Before(deadline) { + ev := tdReceive(1.0) + if ev == nil { + continue + } + if matchExtra(ev, "addproxy") { + if typ, _ := ev["@type"].(string); typ == "error" { + outJSON(false, fmt.Sprintf("addProxy: %v", ev["message"]), 2) + } + if typ, _ := ev["@type"].(string); typ == "proxy" { + if idf, ok := ev["id"].(float64); ok { + proxyID = int64(idf) + break + } + } + outJSON(false, fmt.Sprintf("addProxy unexpected: %#v", ev), 2) + } + } + if proxyID == 0 { + outJSON(false, "timeout waiting for addProxy response", 4) + } + + tdSend(clientID, map[string]interface{}{ + "@type": "pingProxy", "proxy_id": proxyID, "@extra": "pingpx", + }) + + for time.Now().Before(deadline) { + ev := tdReceive(1.0) + if ev == nil { + continue + } + if matchExtra(ev, "pingpx") { + if typ, _ := ev["@type"].(string); typ == "ok" { + outJSON(true, "", 0) + } + if typ, _ := ev["@type"].(string); typ == "error" { + outJSON(false, fmt.Sprintf("pingProxy: %v", ev["message"]), 2) + } + outJSON(false, fmt.Sprintf("pingProxy unexpected: %#v", ev), 2) + } + } + outJSON(false, "timeout waiting for pingProxy", 4) +} + +func mtprotoSecretHex(p *secret.Parsed) string { + var full []byte + switch p.Kind { + case secret.KindDD: + full = append([]byte{0xdd}, p.Key...) + case secret.KindEE: + full = append(append([]byte{0xee}, p.Key...), p.Domain...) + default: + return "" + } + return hex.EncodeToString(full) +} + +func authStateType(m map[string]interface{}) string { + if m == nil { + return "" + } + if m["@type"] != "updateAuthorizationState" { + return "" + } + v, _ := m["authorization_state"].(map[string]interface{}) + if v == nil { + return "" + } + t, _ := v["@type"].(string) + return t +} + +func matchExtra(m map[string]interface{}, want string) bool { + if m == nil { + return false + } + ext, ok := m["@extra"].(string) + return ok && ext == want +} + +func waitAuthState(clientID int, want string, deadline time.Time) bool { + for time.Now().Before(deadline) { + ev := tdReceive(1.0) + if ev == nil { + continue + } + if authStateType(ev) == want { + return true + } + } + return false +} + +func tdSend(clientID int, v interface{}) { + b, err := json.Marshal(v) + if err != nil { + outJSON(false, err.Error(), 2) + } + cs := C.CString(string(b)) + defer C.free(unsafe.Pointer(cs)) + C.td_send(C.int(clientID), cs) +} + +func tdReceive(maxSec float64) map[string]interface{} { + cs := C.td_receive(C.double(maxSec)) + if cs == nil { + return nil + } + s := C.GoString(cs) + var m map[string]interface{} + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil + } + return m +} + +func closeClient(clientID int) { + tdSend(clientID, map[string]interface{}{"@type": "close"}) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + ev := tdReceive(0.5) + if ev == nil { + continue + } + if authStateType(ev) == "authorizationStateClosed" { + break + } + } +} diff --git a/contrib/tdlib-ping/README.md b/contrib/tdlib-ping/README.md new file mode 100644 index 0000000..62fd645 --- /dev/null +++ b/contrib/tdlib-ping/README.md @@ -0,0 +1,40 @@ +# TDLib helper для `mtproxy_checkerd` (Node.js) + +> Предпочтительный вариант — нативный **`tdlib_ping`** в корне репозитория (`cmd/tdlib_ping`, `libtdjson`, официальный JSON API TDLib). Этот каталог остаётся как **legacy** на Node + `prebuilt-tdlib`, если так удобнее в образе. + +Внешний процесс: **TDLib** (`addProxy` + `pingProxy`), как в [telegram-mtproto-proxy-checker](https://github.com/AmirTahaMim/telegram-mtproto-proxy-checker). В stdout печатается **одна строка JSON** — её парсит `mtproxy_checkerd`. + +## Установка + +```bash +cd contrib/tdlib-ping +npm install +``` + +## Запуск вручную + +```bash +node ping.js 'tg://proxy?server=HOST&port=PORT&secret=HEX' +``` + +Успех: строка `{"ok":true,"error":"","exit_code":0}` и код выхода `0`. +Ошибка секрета: `exit_code` `1`. +Прочие ошибки: `exit_code` `2` (или `4` при таймауте внутри скрипта). + +## Docker + +Базовый образ `mtproxy_checker` остаётся без Node/TDLib. Соберите свой слой: установите Node 18+, скопируйте `contrib/tdlib-ping`, выполните `npm install`, задайте: + +```text +MTPROXY_TDLIB_HELPER=/usr/local/bin/mtproxy-tdlib-ping +MTPROXY_TDLIB_TIMEOUT=45s +``` + +Обёртка-скрипт `mtproxy-tdlib-ping`: + +```sh +#!/bin/sh +exec node /opt/tdlib-ping/ping.js "$1" +``` + +TDLib проверка выполняется только при **`MTPROXY_PROBE=fast`** (или пусто). diff --git a/contrib/tdlib-ping/package.json b/contrib/tdlib-ping/package.json new file mode 100644 index 0000000..9c942db --- /dev/null +++ b/contrib/tdlib-ping/package.json @@ -0,0 +1,15 @@ +{ + "name": "mtproxy-checker-tdlib-ping", + "version": "1.0.0", + "private": true, + "description": "TDLib addProxy+pingProxy helper for mtproxy_checkerd (stdout: one JSON line)", + "main": "ping.js", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "prebuilt-tdlib": "^0.1008059.0", + "tdl": "^7.4.1", + "tdl-tdlib-addon": "^1.2.2" + } +} diff --git a/contrib/tdlib-ping/ping.js b/contrib/tdlib-ping/ping.js new file mode 100644 index 0000000..6f6fb58 --- /dev/null +++ b/contrib/tdlib-ping/ping.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * TDLib addProxy + pingProxy; prints one JSON line to stdout for mtproxy_checkerd. + * Based on flow from https://github.com/AmirTahaMim/telegram-mtproto-proxy-checker (MIT). + */ +'use strict'; + +const path = require('path'); +const os = require('os'); +const { Client } = require('tdl'); +const { TDLib } = require('tdl-tdlib-addon'); +const tdl = require('tdl'); + +try { + const { getTdjson } = require('prebuilt-tdlib'); + tdl.configure({ tdjson: getTdjson() }); +} catch (_) { + /* system tdjson */ +} + +function outJson(ok, error, exitCode) { + console.log(JSON.stringify({ ok, error: error || '', exit_code: exitCode })); +} + +function parseProxyUrl(url) { + const tgPattern = /^tg:\/\/proxy\?/; + const httpsPattern = /^https?:\/\/(www\.)?t\.me\/proxy\?/; + if (!tgPattern.test(url) && !httpsPattern.test(url)) { + throw new Error('Invalid proxy URL format'); + } + const params = new URLSearchParams(url.split('?')[1]); + const server = params.get('server'); + const port = parseInt(params.get('port'), 10); + const secret = params.get('secret'); + if (!server || !port || !secret) { + throw new Error('Missing server, port, or secret'); + } + if (isNaN(port) || port < 1 || port > 65535) { + throw new Error('Invalid port'); + } + return { server, port, secret }; +} + +function normalizeSecret(secret) { + const hexPattern = /^[0-9a-fA-F]+$/; + if (hexPattern.test(secret)) { + if (secret.length % 2 !== 0) throw new Error('INVALID_SECRET'); + return Buffer.from(secret, 'hex').toString('hex').toLowerCase(); + } + let normalized = secret.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4; + if (padding !== 0) normalized += '='.repeat(4 - padding); + return Buffer.from(normalized, 'base64').toString('hex').toLowerCase(); +} + +function extractErrorMessage(error) { + if (error.response && error.response._ === 'error') { + return `Error ${error.response.code}: ${error.response.message || ''}`; + } + if (error.message) return error.message; + return String(error); +} + +async function verifyProxy(server, port, hexSecret, timeoutMs) { + const base = path.join(os.tmpdir(), `mtproxy_tdlib_${process.pid}_${Date.now()}`); + const tdlib = new TDLib(); + const apiId = parseInt(process.env.MTPROXY_TD_API_ID || '12345', 10); + const apiHash = process.env.MTPROXY_TD_API_HASH || '0123456789abcdef0123456789abcdef'; + const client = new Client(tdlib, { + apiId, + apiHash, + useTestDc: false, + databaseDirectory: path.join(base, 'db'), + filesDirectory: path.join(base, 'files'), + }); + + try { + try { + await client.connect(); + } catch (e) { + return { ok: false, error: extractErrorMessage(e), code: 2 }; + } + + let addProxyResult; + try { + addProxyResult = await client.invoke({ + _: 'addProxy', + server, + port, + enable: true, + type: { _: 'proxyTypeMtproto', secret: hexSecret }, + }); + } catch (error) { + const msg = extractErrorMessage(error); + if (msg.includes('INVALID_SECRET') || /secret/i.test(msg)) { + return { ok: false, error: 'INVALID_SECRET', code: 1 }; + } + return { ok: false, error: msg, code: 2 }; + } + + if (addProxyResult._ !== 'proxy') { + return { ok: false, error: 'addProxy did not return proxy', code: 2 }; + } + + const proxyId = addProxyResult.id; + const pingPromise = client.invoke({ _: 'pingProxy', proxy_id: proxyId }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('TIMEOUT')), timeoutMs) + ); + + try { + await Promise.race([pingPromise, timeoutPromise]); + return { ok: true, error: '', code: 0 }; + } catch (error) { + const msg = extractErrorMessage(error); + const code = msg.includes('TIMEOUT') || msg.includes('timeout') ? 4 : 2; + return { ok: false, error: msg, code: code > 4 ? 2 : code }; + } + } finally { + try { + await client.close(); + } catch (_) {} + } +} + +async function main() { + const url = process.argv[2]; + if (!url) { + outJson(false, 'usage: node ping.js ', 2); + process.exit(2); + } + const timeoutMs = parseInt(process.env.MTPROXY_TDLIB_PING_MS || '45000', 10) || 45000; + + try { + const { server, port, secret } = parseProxyUrl(url); + const hexSecret = normalizeSecret(secret); + const r = await verifyProxy(server, port, hexSecret, timeoutMs); + outJson(r.ok, r.error, r.code); + process.exit(r.code); + } catch (e) { + if (e.message === 'INVALID_SECRET' || e.message.includes('INVALID_SECRET')) { + outJson(false, 'INVALID_SECRET', 1); + process.exit(1); + } + outJson(false, e.message || String(e), 2); + process.exit(2); + } +} + +main().catch((e) => { + outJson(false, e.message || String(e), 2); + process.exit(2); +}); diff --git a/docs/docker.ru.md b/docs/docker.ru.md index 780b162..b7544e4 100644 --- a/docs/docker.ru.md +++ b/docs/docker.ru.md @@ -50,10 +50,12 @@ docker run -d --name mtproxy-api --restart unless-stopped -p 8080:8080 ` | `MTPROXY_CHECK_INTERVAL` | `5m` | Интервал между циклами (`time.ParseDuration`, например `5m`, `1h`) | | `MTPROXY_HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP | | `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **одной** попытки к выбранному DC; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC). Если задан `MTPROXY_DC_IDS` с несколькими DC, общий бюджет цикла на строку ≈ `таймаут × число_DC` | -| `MTPROXY_PROBE` | *(пусто)* → **`fast`** | `fast` — рукопожатие + init + короткое ожидание как в Telethon TcpMTProxy (#1134): OK, если прокси **не** рвёт TCP сразу после init (входящие байты не обязательны). `deep` — `req_pq`/`resPQ` через DC (строже, дольше) | +| `MTPROXY_PROBE` | *(пусто)* → **`fast`** | `fast` — рукопожатие + init + короткое ожидание как в Telethon TcpMTProxy (#1134): OK, если прокси **не** рвёт TCP сразу после init (входящие байты не обязательны); после основного 2s-окна — ещё несколько коротких `Read`, чтобы поймать **отложенный** FIN/RST (код выхода `3`, как в `deep`). `deep` — после init отправляется `req_pq`, OK если от DC пришло **любое** валидное unencrypted MTProto-сообщение (туннель реально несёт ответ DC; ICMP «ping» до DC через MTProxy невозможен). `deep-strict` — как раньше: ответ должен быть именно `resPQ` | | `MTPROXY_DC_ID` | `2` | Один DC id (аналог `-dc-id` CLI), **игнорируется**, если задан непустой `MTPROXY_DC_IDS` | | `MTPROXY_DC_IDS` | *(пусто)* | Список DC через запятую, например `1,2,3,4,5`: для каждой строки прокси выполняется отдельная проверка на каждый DC **по очереди**. В JSON у записи появляется массив `dcs[]` с результатом по каждому DC. Поле `ok` у строки — **true, если хотя бы один DC прошёл** (как при «есть живой путь к Telegram» при переборе DC) | | `MTPROXY_ALLOWED_IPS` | *(не задана)* | Если задана непустая строка — доступ к **всем** маршрутам только с перечисленных IP/CIDR; остальные получают **403** и JSON `{"error":"forbidden"}`. Формат: через запятую, пробелы допускаются: `192.168.1.10`, `10.0.0.0/8`, IPv6 и CIDR вида `2001:db8::/32`. Учитывается только **`RemoteAddr`** TCP-соединения; заголовок `X-Forwarded-For` **не** используется | +| `MTPROXY_TDLIB_HELPER` | *(пусто)* | Путь к исполняемому **внешнему** helper: один аргумент — строка `tg://` с прокси. В stdout — строка JSON `{"ok":bool,"error":"…","exit_code":int}`. Рекомендуется нативный `tdlib_ping` (см. `cmd/tdlib_ping/README.md`); при необходимости — обёртка `exec node …/ping.js` из `contrib/tdlib-ping`. Запускается **только** при `MTPROXY_PROBE=fast` (или пусто). Без TDLib в образе по умолчанию | +| `MTPROXY_TDLIB_TIMEOUT` | `45s` | Таймаут **одного** вызова helper на строку прокси | ### HTTP @@ -62,7 +64,7 @@ docker run -d --name mtproxy-api --restart unless-stopped -p 8080:8080 ` | GET | `/health` | `200`, `{"status":"ok"}` | | GET | `/api/v1/proxies` | `200`, JSON с полями `cycle_finished_at`, `next_check_after`, массив `proxies` | -Элемент `proxies[]`: `raw_line`, при успешном разборе — `url`, `ok`, `exit_code` (`0` OK — при `MTPROXY_PROBE=fast`: рукопожатие + init и прокси не закрыл TCP сразу (как Telethon #1134); при **`deep`**: дополнительно получен `resPQ` от DC через туннель); `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания в `deep`; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. Если задан `MTPROXY_DC_IDS` с **несколькими** DC, добавляется массив `dcs` (`dc`, `ok`, `exit_code`, `error` по каждому); агрегатный `ok` — true, если **хотя бы один** DC успешен. +Элемент `proxies[]`: поля **`standard`** (наша проверка: `ok`, `exit_code`, `error`, `parse_error`) и **`tdlib`** (результат helper: `ran`, при пропуске — `skipped_reason`; при запуске — `ok`, `exit_code`, `error`, `duration_ms`). Для совместимости дублируются корневые `ok`, `exit_code`, `error`, `parse_error` — те же значения, что и в `standard`. `raw_line`, при успешном разборе — `url`, `checked_at`. Смысл кодов в `standard`: при `MTPROXY_PROBE=fast` — рукопожатие + init (+ ловля отложенного закрытия); при **`deep`** / **`deep-strict`** — см. описание `MTPROXY_PROBE`. Если задан `MTPROXY_DC_IDS` с **несколькими** DC — массив `dcs`; корневой `ok` true, если **хотя бы один** DC успешен. ### Whitelist IP и Docker @@ -282,8 +284,8 @@ docker run --rm registry.example.com/owner/mtproxy_checker:latest \ | Код | Значение | |-----|----------| -| 0 | **`fast`**: Fake-TLS/dd + MTProxy init, прокси не закрыл соединение сразу (как Telethon). **`deep`**: плюс подтверждён `resPQ` от DC | -| 1 | Ошибка (сеть, протокол; в **`deep`** — в т.ч. нет валидного `resPQ`) | +| 0 | **`fast`**: Fake-TLS/dd + MTProxy init, прокси не закрыл соединение сразу (как Telethon). **`deep`**: плюс ответ DC после `req_pq` (валидное unencrypted MTProto). **`deep-strict`**: ответ должен быть `resPQ` | +| 1 | Ошибка (сеть, протокол; в **`deep`** — нет ответа DC; в **`deep-strict`** — нет `resPQ`) | | 2 | Неверные аргументы CLI | | 3 | Прокси закрыл TCP сразу после начального payload | | 4 | Общий таймаут (`-timeout`) | @@ -294,7 +296,8 @@ docker run --rm registry.example.com/owner/mtproxy_checker:latest \ - `connection refused` — порт закрыт или фильтр. - `FAIL: timeout` — нет ответа за `-timeout`. -- `no resPQ from telegram through proxy` — до DC достучаться не удалось или ответ не похож на `resPQ` (часто совпадает с «Недоступен» в клиенте). +- `no reply from telegram DC through proxy (timeout)` — в режиме **`deep`** за время ожидания не получено ни одного валидного unencrypted-ответа DC после `req_pq`. +- `no resPQ from telegram through proxy (tunnel may be broken)` — только в **`deep-strict`**: ответ не распознан как `resPQ`. - Ошибки чтения/проверки ServerHello — несовместимый ответ или обрыв соединения. Для отладки без `--rm` можно посмотреть логи контейнера по id; с `--rm` контейнер удаляется сразу после выхода. diff --git a/internal/checker/checker.go b/internal/checker/checker.go index f89862a..4ac6622 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net" + "syscall" "time" "mtproxy_checker/internal/faketls" @@ -75,7 +76,8 @@ func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16, o if err := tgquick.DrainPostInitEE(ctx, br, conn, dec, 5*time.Second); err != nil { return err } - if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, br); err != nil { + strict := o.Probe == ProbeDeepStrict + if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, br, strict); err != nil { if errors.Is(err, tgquick.ErrPeerClosed) { return fmt.Errorf("%w", ErrProxyClosed) } @@ -95,7 +97,8 @@ func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16, o if o.Probe == ProbeFast { return waitPostPayload(conn, conn) } - if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, nil); err != nil { + strict := o.Probe == ProbeDeepStrict + if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, nil, strict); err != nil { if errors.Is(err, tgquick.ErrPeerClosed) { return fmt.Errorf("%w", ErrProxyClosed) } @@ -104,9 +107,26 @@ func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16, o return nil } +// isPeerClosedReadErr reports RST/half-close style errors from Read (not timeouts). +func isPeerClosedReadErr(err error) bool { + if err == nil { + return false + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return false + } + return errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.ECONNABORTED) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ENOTCONN) +} + // waitPostPayload после init повторяет логику Telethon TcpMTProxy._connect (#1134): // ждём появления данных или таймаут, затем считаем успехом только если соединение не закрыто сразу после payload. // Наличие входящих байтов не обязательно — часть прокси молчит до первого MTProto от клиента. +// После окна idle дополнительно читаем короткими интервалами: часть прокси шлёт FIN/RST сразу после init, +// но не в первых 200ms цикла — без этого fast давал бы ложный OK. func waitPostPayload(r io.Reader, c net.Conn) error { deadline := time.Now().Add(2 * time.Second) buf := make([]byte, 4096) @@ -122,6 +142,37 @@ func waitPostPayload(r io.Reader, c net.Conn) error { _ = c.SetReadDeadline(time.Time{}) return ErrProxyClosed } + if isPeerClosedReadErr(err) { + _ = c.SetReadDeadline(time.Time{}) + return ErrProxyClosed + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + continue + } + _ = c.SetReadDeadline(time.Time{}) + return err + } + } + // Отложенное закрытие со стороны прокси (после того как «молчали» в основном окне). + const probeEach = 400 * time.Millisecond + const probeRounds = 4 + for probes := 0; probes < probeRounds; probes++ { + _ = c.SetReadDeadline(time.Now().Add(probeEach)) + n, err := r.Read(buf) + if n > 0 { + _ = c.SetReadDeadline(time.Time{}) + return nil + } + if err != nil { + if errors.Is(err, io.EOF) { + _ = c.SetReadDeadline(time.Time{}) + return ErrProxyClosed + } + if isPeerClosedReadErr(err) { + _ = c.SetReadDeadline(time.Time{}) + return ErrProxyClosed + } var ne net.Error if errors.As(err, &ne) && ne.Timeout() { continue diff --git a/internal/checker/options.go b/internal/checker/options.go index 06ef01b..1b8dcc1 100644 --- a/internal/checker/options.go +++ b/internal/checker/options.go @@ -8,8 +8,10 @@ type ProbeMode int const ( // ProbeFast: handshake + init, then short wait like Telethon TcpMTProxy (#1134): OK if proxy does not close immediately. ProbeFast ProbeMode = iota - // ProbeDeep: MTProto req_pq and expect resPQ from Telegram DC through the tunnel (stricter; slower). + // ProbeDeep: MTProto req_pq then any valid unencrypted MTProto reply from DC (tunnel carries DC traffic; not only resPQ). ProbeDeep + // ProbeDeepStrict: req_pq and response must be resPQ#05162463 (legacy strict check). + ProbeDeepStrict ) // Options configures Check. Nil or zero value uses ProbeFast. @@ -17,9 +19,11 @@ type Options struct { Probe ProbeMode } -// ParseProbe maps env/flag strings: "", "fast" -> ProbeFast; "deep" -> ProbeDeep. +// ParseProbe maps env/flag strings: "", "fast" -> ProbeFast; "deep" -> ProbeDeep; "deep-strict" -> ProbeDeepStrict. func ParseProbe(s string) ProbeMode { switch strings.ToLower(strings.TrimSpace(s)) { + case "deep-strict", "strict-deep", "respq-strict": + return ProbeDeepStrict case "deep", "respq", "dc": return ProbeDeep default: diff --git a/internal/tdlibexec/tdlibexec.go b/internal/tdlibexec/tdlibexec.go new file mode 100644 index 0000000..1571e15 --- /dev/null +++ b/internal/tdlibexec/tdlibexec.go @@ -0,0 +1,112 @@ +// Package tdlibexec runs an optional external helper (e.g. Node + TDLib) for proxy verification. +// Contract: helper argv = [helperPath, proxyURL]; stdout must contain a JSON line: +// +// {"ok":true,"error":"","exit_code":0} +package tdlibexec + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +// Result is the parsed outcome of a helper invocation. +type Result struct { + OK bool + ExitCode int + Error string + DurationMs int64 +} + +type jsonLine struct { + OK bool `json:"ok"` + Error string `json:"error"` + ExitCode int `json:"exit_code"` +} + +// Run executes helperPath with a single argument proxyURL and parses the last JSON object from stdout. +func Run(ctx context.Context, helperPath, proxyURL string) (Result, error) { + start := time.Now() + ms := func() int64 { return time.Since(start).Milliseconds() } + + if strings.TrimSpace(helperPath) == "" { + return Result{}, errors.New("empty helper path") + } + + cmd := exec.CommandContext(ctx, helperPath, proxyURL) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + duration := ms() + + if ctx.Err() != nil { + return Result{OK: false, ExitCode: 4, Error: "tdlib helper: " + ctx.Err().Error(), DurationMs: duration}, ctx.Err() + } + + if err == nil { + r, perr := parseStdout(stdout.Bytes(), stderr.Bytes(), duration) + if perr != nil { + return Result{OK: false, ExitCode: 1, Error: perr.Error(), DurationMs: duration}, nil + } + return r, nil + } + + var ee *exec.ExitError + if errors.As(err, &ee) { + if r, perr := parseStdout(stdout.Bytes(), stderr.Bytes(), duration); perr == nil { + if r.ExitCode == 0 && !r.OK { + r.ExitCode = ee.ExitCode() + } + if r.ExitCode == 0 && !r.OK { + r.ExitCode = 2 + } + return r, nil + } + msg := strings.TrimSpace(stdout.String()) + if msg == "" { + msg = strings.TrimSpace(stderr.String()) + } + if msg == "" { + msg = err.Error() + } + code := ee.ExitCode() + if code < 0 || code > 4 { + code = 2 + } + return Result{OK: false, ExitCode: code, Error: msg, DurationMs: duration}, nil + } + + return Result{}, fmt.Errorf("tdlib helper: %w", err) +} + +func parseStdout(stdout, stderr []byte, duration int64) (Result, error) { + lines := strings.Split(string(stdout), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || line[0] != '{' { + continue + } + var j jsonLine + if err := json.Unmarshal([]byte(line), &j); err != nil { + continue + } + if j.ExitCode == 0 && !j.OK { + j.ExitCode = 2 + } + if j.Error == "" && !j.OK { + j.Error = "tdlib reported failure" + } + return Result{OK: j.OK, ExitCode: j.ExitCode, Error: j.Error, DurationMs: duration}, nil + } + if len(stderr) > 0 { + return Result{}, fmt.Errorf("no json in stdout: stderr=%s", strings.TrimSpace(string(stderr))) + } + return Result{}, fmt.Errorf("no json line in helper stdout") +} diff --git a/internal/tdlibexec/tdlibexec_test.go b/internal/tdlibexec/tdlibexec_test.go new file mode 100644 index 0000000..51f5f03 --- /dev/null +++ b/internal/tdlibexec/tdlibexec_test.go @@ -0,0 +1,17 @@ +package tdlibexec + +import "testing" + +func TestParseStdout(t *testing.T) { + r, err := parseStdout([]byte("noise\n{\"ok\":true,\"error\":\"\",\"exit_code\":0}\n"), nil, 42) + if err != nil || !r.OK || r.ExitCode != 0 || r.DurationMs != 42 { + t.Fatalf("got %+v err=%v", r, err) + } + r2, err := parseStdout([]byte(`{"ok":false,"error":"bad","exit_code":2}`), nil, 1) + if err != nil || r2.OK || r2.ExitCode != 2 || r2.Error != "bad" { + t.Fatalf("got %+v err=%v", r2, err) + } + if _, err := parseStdout([]byte("no json"), nil, 0); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/tgquick/probe.go b/internal/tgquick/probe.go index d7e0b71..e6699f1 100644 --- a/internal/tgquick/probe.go +++ b/internal/tgquick/probe.go @@ -1,4 +1,4 @@ -// Package tgquick performs a minimal MTProto step (req_pq → resPQ) through an established MTProxy tunnel, +// Package tgquick performs a minimal MTProto step (req_pq → ответ DC) through an established MTProxy tunnel, // matching Telethon's RandomizedIntermediate + MTProxyIO framing. package tgquick @@ -24,9 +24,12 @@ const ( maxRI = 1 << 20 ) -// ErrNoResPQ means no valid resPQ was received from Telegram through the tunnel. +// ErrNoResPQ means strict deep check did not see resPQ#05162463 from DC. var ErrNoResPQ = errors.New("no resPQ from telegram through proxy (tunnel may be broken)") +// ErrNoDCReply means relaxed deep check got no parsable unencrypted MTProto reply from DC before deadline. +var ErrNoDCReply = errors.New("no reply from telegram DC through proxy (timeout)") + // ErrPeerClosed is returned when the remote side closes TCP during the MTProto probe. var ErrPeerClosed = errors.New("connection closed by peer during mtproto probe") @@ -109,6 +112,22 @@ func isResPQ(mtInner []byte) bool { return binary.LittleEndian.Uint32(body[0:4]) == tlResPQ } +// isUnencryptedDCEnvelope reports a valid MTProto unencrypted container (auth_key_id 0) with non-empty body. +// After req_pq the DC normally sends resPQ, but any well-formed unencrypted reply proves the tunnel carried DC traffic. +func isUnencryptedDCEnvelope(mtInner []byte) bool { + if len(mtInner) < 24 { + return false + } + if binary.LittleEndian.Uint64(mtInner[0:8]) != 0 { + return false + } + ml := int(binary.LittleEndian.Uint32(mtInner[16:20])) + if ml < 4 || ml > maxRI || 20+ml > len(mtInner) { + return false + } + return true +} + // DrainPostInitEE reads inbound fake-TLS records after MTProxy init and before req_pq (Telethon TcpMTProxy waits for data ~2s). // Consumes 0x17 payloads with dec so the CTR stream stays aligned with the server; discards MTProto frames until idle. func DrainPostInitEE(ctx context.Context, br *bufio.Reader, conn net.Conn, dec cipher.Stream, maxWait time.Duration) error { @@ -172,9 +191,10 @@ func DrainPostInitEE(ctx context.Context, br *bufio.Reader, conn net.Conn, dec c return nil } -// VerifyResPQ sends req_pq and waits for resPQ on the MTProxy byte stream (decrypt with dec, encrypt with enc). +// VerifyResPQ sends req_pq and waits for a DC reply on the MTProxy byte stream (decrypt with dec, encrypt with enc). +// If strictResPQ is true, the first matching RI frame must be resPQ; otherwise any valid unencrypted MTProto message is enough. // For ee (fake-TLS), pass the same bufio.Reader used after init (and DrainPostInitEE); for dd pass tlsBR=nil. -func VerifyResPQ(ctx context.Context, conn net.Conn, enc, dec cipher.Stream, tlsBR *bufio.Reader) error { +func VerifyResPQ(ctx context.Context, conn net.Conn, enc, dec cipher.Stream, tlsBR *bufio.Reader, strictResPQ bool) error { plain, err := buildReqPQ() if err != nil { return fmt.Errorf("build req_pq: %w", err) @@ -263,14 +283,24 @@ func VerifyResPQ(ctx context.Context, conn net.Conn, enc, dec cipher.Stream, tls if !ok { break } - if isResPQ(frame) { + if strictResPQ { + if isResPQ(frame) { + _ = conn.SetReadDeadline(time.Time{}) + return nil + } + continue + } + if isUnencryptedDCEnvelope(frame) { _ = conn.SetReadDeadline(time.Time{}) return nil } } } _ = conn.SetReadDeadline(time.Time{}) - return ErrNoResPQ + if strictResPQ { + return ErrNoResPQ + } + return ErrNoDCReply } // readNextTLS17Payload reads TLS 1.2-style records from the wire (plaintext record headers). diff --git a/internal/tgquick/probe_test.go b/internal/tgquick/probe_test.go index e8d5e7b..f55e6a9 100644 --- a/internal/tgquick/probe_test.go +++ b/internal/tgquick/probe_test.go @@ -34,3 +34,15 @@ func TestIsResPQ(t *testing.T) { t.Fatal("expected resPQ") } } + +func TestIsUnencryptedDCEnvelope(t *testing.T) { + msgData := make([]byte, 8) + binary.LittleEndian.PutUint32(msgData, 0x11223344) + w := wrapUnencrypted(msgData) + if !isUnencryptedDCEnvelope(w) { + t.Fatal("expected envelope for arbitrary constructor") + } + if isResPQ(w) { + t.Fatal("not resPQ") + } +}