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.
Publish mtproxy_checker Docker image / test (push) Successful in 7s
Publish mtproxy_checker Docker image / build-and-push (push) Successful in 53s

This commit is contained in:
Denozordec
2026-04-11 16:12:45 +07:00
parent 24a7315a16
commit 388784eb5e
17 changed files with 902 additions and 24 deletions
+53 -2
View File
@@ -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
+6 -2
View File
@@ -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:
+112
View File
@@ -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")
}
+17
View File
@@ -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")
}
}
+36 -6
View File
@@ -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).
+12
View File
@@ -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")
}
}