diff --git a/README.md b/README.md index a834168..d06e5cd 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker .\mtproxy_checker.exe --server HOST --port PORT --secret HEX ``` -Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-probe fast|deep` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — `req_pq`/`resPQ` до DC). +Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-dc-ids` (например `1,2,3,4,5` — проверка **каждого** DC по очереди; `-timeout` на **один** DC; общий лимит времени умножается на число DC; код `0`, если **хотя бы один** DC прошёл; список удавшихся DC печатается в stderr), `-probe fast|deep` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — `req_pq`/`resPQ` до DC). -Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс `resPQ` от DC), `1` — ошибка (в **`deep`** в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. +Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс `resPQ` от DC); при **нескольких** DC — `0`, если любой из них OK. `1` — ошибка (в **`deep`** в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. **Docker:** [docs/docker.ru.md](docs/docker.ru.md) — один образ: **с аргументами** после образа — CLI; **без аргументов** — HTTP API (файл со списком `tg://`, интервал, опциональный whitelist IP). diff --git a/cmd/mtproxy_checker/main.go b/cmd/mtproxy_checker/main.go index 5ae1f09..5f0c7b2 100644 --- a/cmd/mtproxy_checker/main.go +++ b/cmd/mtproxy_checker/main.go @@ -10,6 +10,8 @@ import ( "time" "mtproxy_checker/internal/checker" + "mtproxy_checker/internal/checkresult" + "mtproxy_checker/internal/dclist" "mtproxy_checker/internal/parseurl" "mtproxy_checker/internal/secret" ) @@ -21,7 +23,8 @@ func main() { func run() int { timeout := flag.Duration("timeout", 15*time.Second, "overall TCP/handshake timeout") probe := flag.String("probe", "fast", "fast: handshake+init, Telethon-style post-init wait (no immediate close); deep: req_pq/resPQ via DC") - dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header") + dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header (ignored if -dc-ids is set)") + dcIDsFlag := flag.String("dc-ids", "", "comma-separated DC ids to probe in order (e.g. 1,2,3,4,5); OK if any succeeds; timeout is per-DC") server := flag.String("server", "", "proxy hostname (if not using tg:// positional)") portFlag := flag.Int("port", 0, "proxy port (if not using tg:// positional)") secFlag := flag.String("secret", "", "hex secret (if not using tg:// positional)") @@ -60,30 +63,79 @@ func run() int { return 2 } - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() + dcIDs, err := dclist.ParseList(*dcIDsFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "-dc-ids: %v\n", err) + return 2 + } + if dcIDs == nil { + dcIDs = []int16{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) - return 3 - } - if errors.Is(err, context.DeadlineExceeded) { - 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 + total := *timeout + if len(dcIDs) > 1 { + total = *timeout * time.Duration(len(dcIDs)) } - fmt.Println("OK") - return 0 + ctx, cancel := context.WithTimeout(context.Background(), total) + defer cancel() + + if len(dcIDs) == 1 { + dcCtx, dcCancel := context.WithTimeout(ctx, *timeout) + err = checker.Check(dcCtx, host, port, parsed, dcIDs[0], opts) + dcCancel() + if err != nil { + return classifyCLIExit(err) + } + fmt.Println("OK") + return 0 + } + + anyOK := false + var passedDCs []int16 + for _, dc := range dcIDs { + dcCtx, dcCancel := context.WithTimeout(ctx, *timeout) + err := checker.Check(dcCtx, host, port, parsed, dc, opts) + dcCancel() + code, msg := checkresult.Classify(err) + ok := err == nil + if ok { + anyOK = true + passedDCs = append(passedDCs, dc) + } else { + if msg != "" { + fmt.Fprintf(os.Stderr, "FAIL dc=%d (%d): %s\n", dc, code, msg) + } else { + fmt.Fprintf(os.Stderr, "FAIL dc=%d (%d): %v\n", dc, code, err) + } + } + } + if anyOK { + if len(dcIDs) > 1 { + fmt.Fprintf(os.Stderr, "passed DCs: %v\n", passedDCs) + } + fmt.Println("OK") + return 0 + } + return 1 +} + +func classifyCLIExit(err error) int { + if errors.Is(err, checker.ErrProxyClosed) { + fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) + return 3 + } + if errors.Is(err, context.DeadlineExceeded) { + 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 } func hasPrefix(s, p string) bool { diff --git a/cmd/mtproxy_checkerd/main.go b/cmd/mtproxy_checkerd/main.go index f8476a5..fe6dc48 100644 --- a/cmd/mtproxy_checkerd/main.go +++ b/cmd/mtproxy_checkerd/main.go @@ -19,6 +19,7 @@ import ( "mtproxy_checker/internal/allowlist" "mtproxy_checker/internal/checker" "mtproxy_checker/internal/checkresult" + "mtproxy_checker/internal/dclist" "mtproxy_checker/internal/parseurl" "mtproxy_checker/internal/secret" ) @@ -35,7 +36,7 @@ type config struct { checkInterval time.Duration httpAddr string checkTimeout time.Duration - dcID int16 + dcIDs []int16 probe checker.ProbeMode allowedPrefixes []netip.Prefix } @@ -66,14 +67,22 @@ func loadConfig() (*config, error) { if err != nil || checkTimeout <= 0 { return nil, fmt.Errorf("MTPROXY_CHECK_TIMEOUT: invalid duration %q", timeoutStr) } - dcStr := strings.TrimSpace(os.Getenv("MTPROXY_DC_ID")) - if dcStr == "" { - dcStr = "2" + dcIDsRaw := strings.TrimSpace(os.Getenv("MTPROXY_DC_IDS")) + dcIDs, err := dclist.ParseList(dcIDsRaw) + if err != nil { + return nil, fmt.Errorf("MTPROXY_DC_IDS: %w", err) } - var dcParsed int64 - _, err = fmt.Sscanf(dcStr, "%d", &dcParsed) - if err != nil || dcParsed < -32768 || dcParsed > 32767 { - return nil, fmt.Errorf("MTPROXY_DC_ID: invalid int16 %q", dcStr) + if dcIDs == nil { + dcStr := strings.TrimSpace(os.Getenv("MTPROXY_DC_ID")) + if dcStr == "" { + dcStr = "2" + } + var dcParsed int64 + _, err = fmt.Sscanf(dcStr, "%d", &dcParsed) + if err != nil || dcParsed < -32768 || dcParsed > 32767 { + return nil, fmt.Errorf("MTPROXY_DC_ID: invalid int16 %q", dcStr) + } + dcIDs = []int16{int16(dcParsed)} } allowedRaw := os.Getenv("MTPROXY_ALLOWED_IPS") prefixes, err := allowlist.ParseCommaList(allowedRaw) @@ -86,20 +95,28 @@ func loadConfig() (*config, error) { checkInterval: interval, httpAddr: httpAddr, checkTimeout: checkTimeout, - dcID: int16(dcParsed), + dcIDs: dcIDs, probe: probe, allowedPrefixes: prefixes, }, nil } +type dcProbeResult struct { + DC int16 `json:"dc"` + OK bool `json:"ok"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` +} + type proxyEntry struct { - RawLine string `json:"raw_line"` - URL string `json:"url,omitempty"` - OK bool `json:"ok"` - ExitCode int `json:"exit_code"` - Error string `json:"error,omitempty"` - ParseError string `json:"parse_error,omitempty"` - CheckedAt string `json:"checked_at,omitempty"` + RawLine string `json:"raw_line"` + URL string `json:"url,omitempty"` + OK bool `json:"ok"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` + ParseError string `json:"parse_error,omitempty"` + CheckedAt string `json:"checked_at,omitempty"` + DCs []dcProbeResult `json:"dcs,omitempty"` } type snapshot struct { @@ -149,9 +166,33 @@ func readProxyLines(path string) ([]string, error) { return lines, nil } -func checkOneLine(ctx context.Context, line string, dcID int16, probe checker.ProbeMode) proxyEntry { +func aggregateExitFromDCs(dcs []dcProbeResult) int { + has3, has4 := false, false + for _, d := range dcs { + if d.ExitCode == 3 { + has3 = true + } + if d.ExitCode == 4 { + has4 = true + } + } + if has3 { + return 3 + } + if has4 { + return 4 + } + return 1 +} + +func checkOneLine(ctx context.Context, line string, dcIDs []int16, perDCTimeout time.Duration, probe checker.ProbeMode) proxyEntry { now := time.Now().UTC().Format(time.RFC3339) ent := proxyEntry{RawLine: line, CheckedAt: now} + if len(dcIDs) == 0 { + ent.ExitCode = 2 + ent.Error = "no DC ids configured" + return ent + } t, err := parseurl.ParseTGProxy(line) if err != nil { ent.ExitCode = 2 @@ -167,12 +208,46 @@ func checkOneLine(ctx context.Context, line string, dcID int16, probe checker.Pr ent.Error = ent.ParseError return ent } - 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 - if msg != "" { - ent.Error = msg + if len(dcIDs) == 1 { + dcCtx, cancel := context.WithTimeout(ctx, perDCTimeout) + defer cancel() + err = checker.Check(dcCtx, t.Host, t.Port, parsed, dcIDs[0], &checker.Options{Probe: probe}) + code, msg := checkresult.Classify(err) + ent.ExitCode = code + ent.OK = err == nil + if msg != "" { + ent.Error = msg + } + return ent + } + var dcs []dcProbeResult + anyOK := false + for _, dc := range dcIDs { + dcCtx, cancel := context.WithTimeout(ctx, perDCTimeout) + err := checker.Check(dcCtx, t.Host, t.Port, parsed, dc, &checker.Options{Probe: probe}) + cancel() + code, msg := checkresult.Classify(err) + ok := err == nil + if ok { + anyOK = true + } + dcs = append(dcs, dcProbeResult{DC: dc, OK: ok, ExitCode: code, Error: msg}) + } + ent.DCs = dcs + ent.OK = anyOK + if ent.OK { + ent.ExitCode = 0 + } else { + ent.ExitCode = aggregateExitFromDCs(dcs) + var parts []string + for _, d := range dcs { + if d.Error != "" { + parts = append(parts, fmt.Sprintf("dc%d: %s", d.DC, d.Error)) + } else { + parts = append(parts, fmt.Sprintf("dc%d: fail", d.DC)) + } + } + ent.Error = strings.Join(parts, "; ") } return ent } @@ -193,8 +268,12 @@ 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, cfg.probe) + total := cfg.checkTimeout + if len(cfg.dcIDs) > 1 { + total = cfg.checkTimeout * time.Duration(len(cfg.dcIDs)) + } + ctx, cancel := context.WithTimeout(context.Background(), total) + ent := checkOneLine(ctx, line, cfg.dcIDs, cfg.checkTimeout, cfg.probe) cancel() entries = append(entries, ent) } @@ -274,7 +353,7 @@ func run() error { errCh := make(chan error, 1) go func() { - log.Printf("listening on %s, list=%s interval=%s", cfg.httpAddr, cfg.listFile, cfg.checkInterval) + log.Printf("listening on %s, list=%s interval=%s dcs=%v", cfg.httpAddr, cfg.listFile, cfg.checkInterval, cfg.dcIDs) errCh <- srv.ListenAndServe() }() diff --git a/docs/docker.ru.md b/docs/docker.ru.md index 8464e50..780b162 100644 --- a/docs/docker.ru.md +++ b/docs/docker.ru.md @@ -49,9 +49,10 @@ 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` если не задано) | Таймаут **всей** одной проверки; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC) | +| `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **одной** попытки к выбранному DC; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC). Если задан `MTPROXY_DC_IDS` с несколькими DC, общий бюджет цикла на строку ≈ `таймаут × число_DC` | | `MTPROXY_PROBE` | *(пусто)* → **`fast`** | `fast` — рукопожатие + init + короткое ожидание как в Telethon TcpMTProxy (#1134): OK, если прокси **не** рвёт TCP сразу после init (входящие байты не обязательны). `deep` — `req_pq`/`resPQ` через DC (строже, дольше) | -| `MTPROXY_DC_ID` | `2` | DC id (аналог `-dc-id` CLI) | +| `MTPROXY_DC_ID` | `2` | Один DC id (аналог `-dc-id` CLI), **игнорируется**, если задан непустой `MTPROXY_DC_IDS` | +| `MTPROXY_DC_IDS` | *(пусто)* | Список DC через запятую, например `1,2,3,4,5`: для каждой строки прокси выполняется отдельная проверка на каждый DC **по очереди**. В JSON у записи появляется массив `dcs[]` с результатом по каждому DC. Поле `ok` у строки — **true, если хотя бы один DC прошёл** (как при «есть живой путь к Telegram» при переборе DC) | | `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` **не** используется | ### HTTP @@ -61,7 +62,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_PROBE=fast`: рукопожатие + init и прокси не закрыл TCP сразу (как Telethon #1134); при **`deep`**: дополнительно получен `resPQ` от DC через туннель); `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания в `deep`; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. +Элемент `proxies[]`: `raw_line`, при успешном разборе — `url`, `ok`, `exit_code` (`0` OK — при `MTPROXY_PROBE=fast`: рукопожатие + init и прокси не закрыл TCP сразу (как Telethon #1134); при **`deep`**: дополнительно получен `resPQ` от DC через туннель); `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания в `deep`; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. Если задан `MTPROXY_DC_IDS` с **несколькими** DC, добавляется массив `dcs` (`dc`, `ok`, `exit_code`, `error` по каждому); агрегатный `ok` — true, если **хотя бы один** DC успешен. ### Whitelist IP и Docker diff --git a/internal/dclist/dclist.go b/internal/dclist/dclist.go new file mode 100644 index 0000000..9b7b6c5 --- /dev/null +++ b/internal/dclist/dclist.go @@ -0,0 +1,36 @@ +// Package dclist parses comma-separated Telegram DC ids (int16). +package dclist + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseList parses "1,2,5" or "2". Empty string returns (nil, nil) — caller uses single-DC default. +func ParseList(s string) ([]int16, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + parts := strings.Split(s, ",") + out := make([]int16, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + v, err := strconv.ParseInt(p, 10, 16) + if err != nil { + return nil, fmt.Errorf("dc id %q: %w", p, err) + } + if v < -32768 || v > 32767 { + return nil, fmt.Errorf("dc id %d out of int16 range", v) + } + out = append(out, int16(v)) + } + if len(out) == 0 { + return nil, fmt.Errorf("empty dc list after parsing %q", s) + } + return out, nil +} diff --git a/internal/dclist/dclist_test.go b/internal/dclist/dclist_test.go new file mode 100644 index 0000000..128bf94 --- /dev/null +++ b/internal/dclist/dclist_test.go @@ -0,0 +1,24 @@ +package dclist + +import ( + "reflect" + "testing" +) + +func TestParseList(t *testing.T) { + got, err := ParseList("") + if err != nil || got != nil { + t.Fatalf("empty: got %v err %v", got, err) + } + got, err = ParseList(" 1 , 2 , 5 ") + if err != nil || !reflect.DeepEqual(got, []int16{1, 2, 5}) { + t.Fatalf("1,2,5: got %v err %v", got, err) + } + got, err = ParseList("-1") + if err != nil || !reflect.DeepEqual(got, []int16{-1}) { + t.Fatalf("-1: got %v err %v", got, err) + } + if _, err := ParseList("foo"); err == nil { + t.Fatal("expected error") + } +}