diff --git a/README.md b/README.md index ac265db..6bebbda 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker .\mtproxy_checker.exe --server HOST --port PORT --secret HEX ``` -Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2). +Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-probe fast|deep` (по умолчанию **`fast`** — быстрая проверка; `deep` — MTProto до DC). Код выхода: `0` — OK (через прокси получен ответ Telegram DC на MTProto `req_pq` — `resPQ`), `1` — ошибка (в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. diff --git a/cmd/mtproxy_checker/main.go b/cmd/mtproxy_checker/main.go index 9532cf9..c9a63b9 100644 --- a/cmd/mtproxy_checker/main.go +++ b/cmd/mtproxy_checker/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "net" "os" "time" @@ -19,6 +20,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+inbound byte; deep: MTProto req_pq/resPQ via DC (stricter, slower)") dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header") server := flag.String("server", "", "proxy hostname (if not using tg:// positional)") portFlag := flag.Int("port", 0, "proxy port (if not using tg:// positional)") @@ -61,7 +63,8 @@ func run() int { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() - err = checker.Check(ctx, host, port, parsed, int16(*dcID)) + opts := &checker.Options{Probe: checker.ParseProbe(*probe)} + err = checker.Check(ctx, host, port, parsed, int16(*dcID), opts) if err != nil { if errors.Is(err, checker.ErrProxyClosed) { fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) @@ -71,6 +74,11 @@ func run() int { fmt.Fprintf(os.Stderr, "FAIL: timeout\n") return 4 } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + fmt.Fprintf(os.Stderr, "FAIL: timeout\n") + return 4 + } fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) return 1 } diff --git a/cmd/mtproxy_checkerd/main.go b/cmd/mtproxy_checkerd/main.go index 9d5901f..f8476a5 100644 --- a/cmd/mtproxy_checkerd/main.go +++ b/cmd/mtproxy_checkerd/main.go @@ -31,11 +31,12 @@ func main() { } type config struct { - listFile string - checkInterval time.Duration - httpAddr string - checkTimeout time.Duration - dcID int16 + listFile string + checkInterval time.Duration + httpAddr string + checkTimeout time.Duration + dcID int16 + probe checker.ProbeMode allowedPrefixes []netip.Prefix } @@ -79,12 +80,14 @@ func loadConfig() (*config, error) { if err != nil { return nil, err } + probe := checker.ParseProbe(os.Getenv("MTPROXY_PROBE")) return &config{ listFile: listFile, checkInterval: interval, httpAddr: httpAddr, checkTimeout: checkTimeout, dcID: int16(dcParsed), + probe: probe, allowedPrefixes: prefixes, }, nil } @@ -146,7 +149,7 @@ func readProxyLines(path string) ([]string, error) { return lines, nil } -func checkOneLine(ctx context.Context, line string, dcID int16) proxyEntry { +func checkOneLine(ctx context.Context, line string, dcID int16, probe checker.ProbeMode) proxyEntry { now := time.Now().UTC().Format(time.RFC3339) ent := proxyEntry{RawLine: line, CheckedAt: now} t, err := parseurl.ParseTGProxy(line) @@ -164,7 +167,7 @@ func checkOneLine(ctx context.Context, line string, dcID int16) proxyEntry { ent.Error = ent.ParseError return ent } - err = checker.Check(ctx, t.Host, t.Port, parsed, dcID) + err = checker.Check(ctx, t.Host, t.Port, parsed, dcID, &checker.Options{Probe: probe}) code, msg := checkresult.Classify(err) ent.ExitCode = code ent.OK = err == nil @@ -191,7 +194,7 @@ func runCycle(cfg *config, st *store) { entries := make([]proxyEntry, 0, len(lines)) for _, line := range lines { ctx, cancel := context.WithTimeout(context.Background(), cfg.checkTimeout) - ent := checkOneLine(ctx, line, cfg.dcID) + ent := checkOneLine(ctx, line, cfg.dcID, cfg.probe) cancel() entries = append(entries, ent) } diff --git a/docs/docker.ru.md b/docs/docker.ru.md index 6cbd3d7..bb8bf30 100644 --- a/docs/docker.ru.md +++ b/docs/docker.ru.md @@ -49,7 +49,8 @@ docker run -d --name mtproxy-api --restart unless-stopped -p 8080:8080 ` | `MTPROXY_LIST_FILE` | `/data/proxies.txt` | Путь к файлу: одна `tg://` ссылка на строку; пустые строки и строки с `#` в начале пропускаются | | `MTPROXY_CHECK_INTERVAL` | `5m` | Интервал между циклами (`time.ParseDuration`, например `5m`, `1h`) | | `MTPROXY_HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP | -| `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **всей** одной проверки: TCP + Fake-TLS + MTProxy init + drain + `req_pq`/`resPQ` до DC; при медленной сети увеличьте | +| `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **всей** одной проверки; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC) | +| `MTPROXY_PROBE` | *(пусто)* → **`fast`** | `fast` — как в ранних релизах: рукопожатие + init + любой входящий байт. `deep` — `req_pq`/`resPQ` через DC (строже, дольше) | | `MTPROXY_DC_ID` | `2` | DC id (аналог `-dc-id` CLI) | | `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` **не** используется | diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 2249d65..194ec2a 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "net" "time" @@ -17,25 +18,29 @@ import ( // ErrProxyClosed indicates the peer closed the TCP connection during the check (Telethon #1134 style). var ErrProxyClosed = errors.New("mtproxy closed connection after initial payload") -// 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 { +// ErrNoDataAfterHeader is returned in ProbeFast when the proxy sends nothing after the init payload within the wait window. +var ErrNoDataAfterHeader = errors.New("no data from proxy after mtproxy header (timeout)") + +// Check runs Fake-TLS/dd handshake and MTProxy init; further steps depend on opts.Probe (see Options). opts nil => ProbeFast. +func Check(ctx context.Context, host string, port int, parsed *secret.Parsed, dcID int16, opts *Options) error { conn, err := dialTCP(ctx, host, port) if err != nil { return fmt.Errorf("tcp dial: %w", err) } defer conn.Close() + o := effectiveOpts(opts) switch parsed.Kind { case secret.KindEE: - return checkEE(ctx, conn, parsed, dcID) + return checkEE(ctx, conn, parsed, dcID, o) case secret.KindDD: - return checkDD(ctx, conn, parsed, dcID) + return checkDD(ctx, conn, parsed, dcID, o) default: return fmt.Errorf("unknown secret kind") } } -func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) error { +func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16, o *Options) error { // ee-секрет хранит домен как «сырой» хвост (часто 0xd0 + ASCII hostname). В TLS SNI нужен только hostname, // как в официальном клиенте Telegram — иначе прокси сбрасывает соединение до ServerHello. sni := faketls.SNIDomain(p.Domain) @@ -64,6 +69,9 @@ func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) e if err := faketls.WriteTLSApplicationData(conn, hdr); err != nil { return fmt.Errorf("write mtproxy header: %w", err) } + if o.Probe == ProbeFast { + return waitPostPayload(conn) + } br := bufio.NewReader(conn) if err := tgquick.DrainPostInitEE(ctx, br, conn, dec, 2*time.Second); err != nil { return err @@ -77,7 +85,7 @@ func checkEE(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) e return nil } -func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) error { +func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16, o *Options) error { hdr, enc, dec, err := mtproxy.InitHeader(p.Key, dcID) if err != nil { return fmt.Errorf("mtproxy header: %w", err) @@ -85,6 +93,9 @@ func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) e if _, err := conn.Write(hdr); err != nil { return fmt.Errorf("write mtproxy header: %w", err) } + if o.Probe == ProbeFast { + return waitPostPayload(conn) + } if err := tgquick.VerifyResPQ(ctx, conn, enc, dec, nil); err != nil { if errors.Is(err, tgquick.ErrPeerClosed) { return fmt.Errorf("%w", ErrProxyClosed) @@ -93,3 +104,30 @@ func checkDD(ctx context.Context, conn net.Conn, p *secret.Parsed, dcID int16) e } return nil } + +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 + } + } + _ = conn.SetReadDeadline(time.Time{}) + return ErrNoDataAfterHeader +} diff --git a/internal/checker/options.go b/internal/checker/options.go new file mode 100644 index 0000000..1eb2416 --- /dev/null +++ b/internal/checker/options.go @@ -0,0 +1,35 @@ +package checker + +import "strings" + +// ProbeMode selects how strictly we validate after MTProxy init. +type ProbeMode int + +const ( + // ProbeFast: handshake + init, then any inbound data within a short window (legacy behavior; fast). + ProbeFast ProbeMode = iota + // ProbeDeep: MTProto req_pq and expect resPQ from Telegram DC through the tunnel (stricter; slower). + ProbeDeep +) + +// Options configures Check. Nil or zero value uses ProbeFast. +type Options struct { + Probe ProbeMode +} + +// ParseProbe maps env/flag strings: "", "fast" -> ProbeFast; "deep" -> ProbeDeep. +func ParseProbe(s string) ProbeMode { + switch strings.ToLower(strings.TrimSpace(s)) { + case "deep", "respq", "dc": + return ProbeDeep + default: + return ProbeFast + } +} + +func effectiveOpts(opts *Options) *Options { + if opts == nil { + return &Options{Probe: ProbeFast} + } + return opts +} diff --git a/internal/checkresult/checkresult.go b/internal/checkresult/checkresult.go index 9435527..da064cd 100644 --- a/internal/checkresult/checkresult.go +++ b/internal/checkresult/checkresult.go @@ -16,6 +16,9 @@ func Classify(err error) (exitCode int, message string) { if errors.Is(err, checker.ErrProxyClosed) { return 3, err.Error() } + if errors.Is(err, checker.ErrNoDataAfterHeader) { + return 1, err.Error() + } if errors.Is(err, context.DeadlineExceeded) { return 4, "timeout" }