From 7d13c599c46394819e579b57dacfe1ff59050b36 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 11 Apr 2026 01:44:53 +0700 Subject: [PATCH] Refactor checker logic to integrate context handling in MTProxy checks. Update error handling to utilize tgquick for response verification, replacing previous waitPostPayload function. Simplify error messages for closed connections. --- README.md | 2 +- docs/docker.ru.md | 8 +- internal/checker/checker.go | 62 +++------ internal/tgquick/probe.go | 225 +++++++++++++++++++++++++++++++++ internal/tgquick/probe_test.go | 36 ++++++ 5 files changed, 287 insertions(+), 46 deletions(-) create mode 100644 internal/tgquick/probe.go create mode 100644 internal/tgquick/probe_test.go diff --git a/README.md b/README.md index aa338d7..ac265db 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2). -Код выхода: `0` — OK (после рукопожатия от прокси получен хотя бы один байт данных), `1` — ошибка (в т.ч. «тишина» после MTProxy-заголовка), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. +Код выхода: `0` — OK (через прокси получен ответ Telegram DC на MTProto `req_pq` — `resPQ`), `1` — ошибка (в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. **Docker:** [docs/docker.ru.md](docs/docker.ru.md) — один образ: **с аргументами** после образа — CLI; **без аргументов** — HTTP API (файл со списком `tg://`, интервал, опциональный whitelist IP). diff --git a/docs/docker.ru.md b/docs/docker.ru.md index 5e0c1fc..56919e0 100644 --- a/docs/docker.ru.md +++ b/docs/docker.ru.md @@ -60,7 +60,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-заголовка прочитан хотя бы один байт от сервера; `1` ошибка проверки, в т.ч. если за окно ожидания **нет ни одного байта** ответа; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. +Элемент `proxies[]`: `raw_line`, при успешном разборе — `url`, `ok`, `exit_code` (`0` OK — через туннель прокси получен ответ DC на MTProto `req_pq` (`resPQ`), как при реальной проверке клиентом; `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. ### Whitelist IP и Docker @@ -280,8 +280,8 @@ docker run --rm registry.example.com/owner/mtproxy_checker:latest \ | Код | Значение | |-----|----------| -| 0 | Проверка прошла: после MTProxy-заголовка получен хотя бы один байт от прокси | -| 1 | Ошибка (сеть, протокол, неверный ответ; в т.ч. **нет данных** от прокси после заголовка за окно ожидания) | +| 0 | Проверка прошла: Fake-TLS/dd + MTProxy init, затем MTProto `req_pq` и ответ `resPQ` от Telegram DC через прокси | +| 1 | Ошибка (сеть, протокол; в т.ч. **нет валидного `resPQ`** — туннель до DC не подтверждён) | | 2 | Неверные аргументы CLI | | 3 | Прокси закрыл TCP сразу после начального payload | | 4 | Общий таймаут (`-timeout`) | @@ -292,7 +292,7 @@ docker run --rm registry.example.com/owner/mtproxy_checker:latest \ - `connection refused` — порт закрыт или фильтр. - `FAIL: timeout` — нет ответа за `-timeout`. -- `no data from proxy after mtproxy header` — рукопожатие прошло, но прокси не прислал ни одного байта в фазе после заголовка (часто совпадает с «Недоступен» в клиенте Telegram). +- `no resPQ from telegram through proxy` — до DC достучаться не удалось или ответ не похож на `resPQ` (часто совпадает с «Недоступен» в клиенте). - Ошибки чтения/проверки ServerHello — несовместимый ответ или обрыв соединения. Для отладки без `--rm` можно посмотреть логи контейнера по id; с `--rm` контейнер удаляется сразу после выхода. diff --git a/internal/checker/checker.go b/internal/checker/checker.go index b92726e..35e426e 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -4,23 +4,18 @@ import ( "context" "errors" "fmt" - "io" "net" - "time" "mtproxy_checker/internal/faketls" "mtproxy_checker/internal/mtproxy" "mtproxy_checker/internal/secret" + "mtproxy_checker/internal/tgquick" ) -// ErrProxyClosed indicates the MTProxy dropped the TCP connection right after the probe (Telethon #1134 style). +// ErrProxyClosed indicates the peer closed the TCP connection during the check (Telethon #1134 style). var ErrProxyClosed = errors.New("mtproxy closed connection after initial payload") -// ErrNoDataAfterHeader indicates the proxy accepted the probe but sent no application data back within the wait window. -// Treating silence as success matched some servers but often disagrees with Telegram client "unavailable". -var ErrNoDataAfterHeader = errors.New("no data from proxy after mtproxy header (timeout)") - -// Check runs the handshake probe for the given parsed secret. +// Check runs Fake-TLS/dd handshake, MTProxy init, then a minimal MTProto req_pq and expects resPQ from Telegram DC (same idea as the mobile client path). func Check(ctx context.Context, host string, port int, parsed *secret.Parsed, dcID int16) error { conn, err := dialTCP(ctx, host, port) if err != nil { @@ -30,15 +25,15 @@ func Check(ctx context.Context, host string, port int, parsed *secret.Parsed, dc switch parsed.Kind { case secret.KindEE: - return checkEE(conn, parsed, dcID) + return checkEE(ctx, conn, parsed, dcID) case secret.KindDD: - return checkDD(conn, parsed, dcID) + return checkDD(ctx, conn, parsed, dcID) default: return fmt.Errorf("unknown secret kind") } } -func checkEE(conn net.Conn, p *secret.Parsed, dcID int16) error { +func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) error { // ee-секрет хранит домен как «сырой» хвост (часто 0xd0 + ASCII hostname). В TLS SNI нужен только hostname, // как в официальном клиенте Telegram — иначе прокси сбрасывает соединение до ServerHello. sni := faketls.SNIDomain(p.Domain) @@ -60,50 +55,35 @@ func checkEE(conn net.Conn, p *secret.Parsed, dcID int16) error { return fmt.Errorf("verify server hello: %w", err) } - hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID) + hdr, enc, dec, err := mtproxy.InitHeader(p.Key, dcID) if err != nil { return fmt.Errorf("mtproxy header: %w", err) } if err := faketls.WriteTLSApplicationData(conn, hdr); err != nil { return fmt.Errorf("write mtproxy header: %w", err) } - return waitPostPayload(conn) + if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, true); err != nil { + if errors.Is(err, tgquick.ErrPeerClosed) { + return fmt.Errorf("%w", ErrProxyClosed) + } + return err + } + return nil } -func checkDD(conn net.Conn, p *secret.Parsed, dcID int16) error { - hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID) +func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) error { + hdr, enc, dec, err := mtproxy.InitHeader(p.Key, dcID) if err != nil { return fmt.Errorf("mtproxy header: %w", err) } if _, err := conn.Write(hdr); err != nil { return fmt.Errorf("write mtproxy header: %w", err) } - return waitPostPayload(conn) -} - -func waitPostPayload(conn net.Conn) error { - deadline := time.Now().Add(2 * time.Second) - buf := make([]byte, 4096) - for time.Now().Before(deadline) { - _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) - n, err := conn.Read(buf) - if n > 0 { - _ = conn.SetReadDeadline(time.Time{}) - return nil - } - if err != nil { - if errors.Is(err, io.EOF) { - _ = conn.SetReadDeadline(time.Time{}) - return ErrProxyClosed - } - var ne net.Error - if errors.As(err, &ne) && ne.Timeout() { - continue - } - _ = conn.SetReadDeadline(time.Time{}) - return err + if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, false); err != nil { + if errors.Is(err, tgquick.ErrPeerClosed) { + return fmt.Errorf("%w", ErrProxyClosed) } + return err } - _ = conn.SetReadDeadline(time.Time{}) - return ErrNoDataAfterHeader + return nil } diff --git a/internal/tgquick/probe.go b/internal/tgquick/probe.go new file mode 100644 index 0000000..ab56c1c --- /dev/null +++ b/internal/tgquick/probe.go @@ -0,0 +1,225 @@ +// Package tgquick performs a minimal MTProto step (req_pq → resPQ) through an established MTProxy tunnel, +// matching Telethon's RandomizedIntermediate + MTProxyIO framing. +package tgquick + +import ( + "bufio" + "context" + "crypto/cipher" + crand "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" + + "mtproxy_checker/internal/faketls" +) + +const ( + tlReqPQ = 0xd712e4be + tlResPQ = 0x05162463 + maxRI = 1 << 20 +) + +// ErrNoResPQ means no valid resPQ was received from Telegram through the tunnel. +var ErrNoResPQ = errors.New("no resPQ from telegram through proxy (tunnel may be broken)") + +// ErrPeerClosed is returned when the remote side closes TCP during the MTProto probe. +var ErrPeerClosed = errors.New("connection closed by peer during mtproto probe") + +func newMessageID() int64 { + t := time.Now().UnixNano() + return (t / 4) &^ 3 +} + +func buildReqPQ() ([]byte, error) { + nonce := make([]byte, 16) + if _, err := io.ReadFull(crand.Reader, nonce); err != nil { + return nil, err + } + body := make([]byte, 4+16) + binary.LittleEndian.PutUint32(body, tlReqPQ) + copy(body[4:], nonce) + return wrapUnencrypted(body), nil +} + +func wrapUnencrypted(msgData []byte) []byte { + out := make([]byte, 8+8+4+len(msgData)) + // auth_key_id = 0 + binary.LittleEndian.PutUint64(out[8:], uint64(newMessageID())) + binary.LittleEndian.PutUint32(out[16:], uint32(len(msgData))) + copy(out[20:], msgData) + return out +} + +// randomizedIntermediateEncode packs payload with 0–3 random padding bytes (Telethon RandomizedIntermediatePacketCodec). +func randomizedIntermediateEncode(payload []byte) ([]byte, error) { + var rnd [1]byte + if _, err := crand.Read(rnd[:]); err != nil { + return nil, err + } + pad := int(rnd[0] % 4) + padding := make([]byte, pad) + if _, err := crand.Read(padding); err != nil { + return nil, err + } + body := append(append([]byte{}, payload...), padding...) + out := make([]byte, 4+len(body)) + binary.LittleEndian.PutUint32(out, uint32(len(body))) + copy(out[4:], body) + return out, nil +} + +func popRI(acc *[]byte) ([]byte, bool) { + a := *acc + if len(a) < 4 { + return nil, false + } + L := int(binary.LittleEndian.Uint32(a[0:4])) + if L < 0 || L > maxRI || len(a) < 4+L { + return nil, false + } + frame := a[4 : 4+L] + *acc = append([]byte(nil), a[4+L:]...) + pad := len(frame) % 4 + if pad > 0 { + frame = frame[:len(frame)-pad] + } + return frame, true +} + +func isResPQ(mtInner []byte) bool { + if len(mtInner) < 20 { + 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 + } + body := mtInner[20 : 20+ml] + return binary.LittleEndian.Uint32(body[0:4]) == tlResPQ +} + +// VerifyResPQ sends req_pq and waits for resPQ on the MTProxy byte stream (decrypt with dec, encrypt with enc). +// If eeTLS is true, each MTProxy chunk is wrapped in TLS 1.2 application records (0x17) as in checkEE. +func VerifyResPQ(ctx context.Context, conn net.Conn, enc, dec cipher.Stream, eeTLS bool) error { + plain, err := buildReqPQ() + if err != nil { + return fmt.Errorf("build req_pq: %w", err) + } + ri, err := randomizedIntermediateEncode(plain) + if err != nil { + return fmt.Errorf("randomized intermediate: %w", err) + } + wire := append([]byte(nil), ri...) + enc.XORKeyStream(wire, wire) + if eeTLS { + if err := faketls.WriteTLSApplicationData(conn, wire); err != nil { + return fmt.Errorf("write tls app (req_pq): %w", err) + } + } else { + if _, err := conn.Write(wire); err != nil { + return fmt.Errorf("write req_pq: %w", err) + } + } + + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(15 * time.Second) + } + + var acc []byte + var br *bufio.Reader + if eeTLS { + br = bufio.NewReader(conn) + } + + const maxAcc = 256 << 10 + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + chunkDeadline := time.Now().Add(3 * time.Second) + if chunkDeadline.After(deadline) { + chunkDeadline = deadline + } + _ = conn.SetReadDeadline(chunkDeadline) + var chunk []byte + var rerr error + if eeTLS { + chunk, rerr = readOneTLSApplicationRecord(br) + } else { + chunk, rerr = readAtMost(conn, 65536) + } + if rerr != nil { + if ne, ok := rerr.(net.Error); ok && ne.Timeout() { + if time.Now().Before(deadline) { + continue + } + break + } + if errors.Is(rerr, io.EOF) { + return fmt.Errorf("%w", ErrPeerClosed) + } + return fmt.Errorf("read mtproxy stream: %w", rerr) + } + if len(chunk) == 0 { + continue + } + dec.XORKeyStream(chunk, chunk) + acc = append(acc, chunk...) + if len(acc) > maxAcc { + return fmt.Errorf("mtproxy read buffer overflow") + } + for { + frame, ok := popRI(&acc) + if !ok { + break + } + if isResPQ(frame) { + _ = conn.SetReadDeadline(time.Time{}) + return nil + } + } + } + _ = conn.SetReadDeadline(time.Time{}) + return ErrNoResPQ +} + +func readOneTLSApplicationRecord(br *bufio.Reader) ([]byte, error) { + h := make([]byte, 5) + if _, err := io.ReadFull(br, h); err != nil { + return nil, err + } + if h[0] != 0x17 { + return nil, fmt.Errorf("unexpected tls record type 0x%02x", h[0]) + } + n := int(binary.BigEndian.Uint16(h[3:5])) + if n <= 0 || n > 1<<20 { + return nil, fmt.Errorf("invalid tls app length %d", n) + } + p := make([]byte, n) + if _, err := io.ReadFull(br, p); err != nil { + return nil, err + } + return p, nil +} + +func readAtMost(conn net.Conn, max int) ([]byte, error) { + buf := make([]byte, max) + n, err := conn.Read(buf) + if n > 0 { + if err == io.EOF { + err = nil + } + return buf[:n], err + } + return nil, err +} diff --git a/internal/tgquick/probe_test.go b/internal/tgquick/probe_test.go new file mode 100644 index 0000000..e8d5e7b --- /dev/null +++ b/internal/tgquick/probe_test.go @@ -0,0 +1,36 @@ +package tgquick + +import ( + "encoding/binary" + "testing" +) + +func TestPopRI_RoundTrip(t *testing.T) { + payload := []byte{1, 2, 3, 4, 5, 6, 7, 8} + enc, err := randomizedIntermediateEncode(payload) + if err != nil { + t.Fatal(err) + } + var acc []byte + acc = append(acc, enc...) + got, ok := popRI(&acc) + if !ok || len(acc) != 0 { + t.Fatalf("popRI: ok=%v rest=%d", ok, len(acc)) + } + if string(got) != string(payload) { + t.Fatalf("payload mismatch: %v vs %v", got, payload) + } +} + +func TestIsResPQ(t *testing.T) { + nonce := make([]byte, 16) + // resPQ: constructor + nonce(16) + server_nonce(16) + pq bytes + Vector + msgData := make([]byte, 4+16+16+4+4) + binary.LittleEndian.PutUint32(msgData, tlResPQ) + copy(msgData[4:20], nonce) + copy(msgData[20:36], nonce) + // pq empty, fingerprints empty + if !isResPQ(wrapUnencrypted(msgData)) { + t.Fatal("expected resPQ") + } +}