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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user