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.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
+4
-4
@@ -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` контейнер удаляется сразу после выхода.
|
||||
|
||||
+21
-41
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<long>
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user