From 5a66f0f2e8948c2bd8eecf3569abafeeabc477d6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 11 Apr 2026 00:50:57 +0700 Subject: [PATCH] Enhance Dockerfile to build and include a new HTTP API service (`mtproxy_checkerd`) alongside the existing CLI tool (`mtproxy_checker`). Update README and documentation to reflect the new service and its usage, including environment variables and Docker run instructions. --- Dockerfile | 5 +- README.md | 2 +- cmd/mtproxy_checkerd/main.go | 290 +++++++++++++++++++++++++++ docker-compose.api.yaml | 19 ++ docs/docker.ru.md | 58 +++++- internal/allowlist/allowlist.go | 60 ++++++ internal/allowlist/allowlist_test.go | 32 +++ internal/checkresult/checkresult.go | 22 ++ 8 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 cmd/mtproxy_checkerd/main.go create mode 100644 docker-compose.api.yaml create mode 100644 internal/allowlist/allowlist.go create mode 100644 internal/allowlist/allowlist_test.go create mode 100644 internal/checkresult/checkresult.go diff --git a/Dockerfile b/Dockerfile index 4fa44f9..c79f09c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,13 @@ RUN apk add --no-cache ca-certificates git COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mtproxy_checker ./cmd/mtproxy_checker +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mtproxy_checker ./cmd/mtproxy_checker \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mtproxy_checkerd ./cmd/mtproxy_checkerd # Runtime FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --from=build /mtproxy_checker /usr/local/bin/mtproxy_checker +COPY --from=build /mtproxy_checkerd /usr/local/bin/mtproxy_checkerd +EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/mtproxy_checker"] diff --git a/README.md b/README.md index ffbbab3..a8b26d4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker Код выхода: `0` — OK, `1` — ошибка, `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. -**Docker (описание образа, Linux, несколько прокси подряд):** [docs/docker.ru.md](docs/docker.ru.md). +**Docker:** [docs/docker.ru.md](docs/docker.ru.md) — образ CLI, несколько прокси из файла, а также контейнер с **HTTP API** (`mtproxy_checkerd`, файл со списком `tg://`, интервал проверки, опциональный whitelist IP). ### Если в Telegram прокси «Available», а утилита падает на `read server hello` diff --git a/cmd/mtproxy_checkerd/main.go b/cmd/mtproxy_checkerd/main.go new file mode 100644 index 0000000..8bc0a44 --- /dev/null +++ b/cmd/mtproxy_checkerd/main.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/netip" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "mtproxy_checker/internal/allowlist" + "mtproxy_checker/internal/checker" + "mtproxy_checker/internal/checkresult" + "mtproxy_checker/internal/parseurl" + "mtproxy_checker/internal/secret" +) + +func main() { + log.SetFlags(log.LstdFlags | log.LUTC) + if err := run(); err != nil { + log.Fatal(err) + } +} + +type config struct { + listFile string + checkInterval time.Duration + httpAddr string + checkTimeout time.Duration + dcID int16 + allowedPrefixes []netip.Prefix +} + +func loadConfig() (*config, error) { + listFile := strings.TrimSpace(os.Getenv("MTPROXY_LIST_FILE")) + if listFile == "" { + listFile = "/data/proxies.txt" + } + intervalStr := strings.TrimSpace(os.Getenv("MTPROXY_CHECK_INTERVAL")) + if intervalStr == "" { + intervalStr = "5m" + } + interval, err := time.ParseDuration(intervalStr) + if err != nil || interval <= 0 { + return nil, fmt.Errorf("MTPROXY_CHECK_INTERVAL: invalid duration %q", intervalStr) + } + httpAddr := strings.TrimSpace(os.Getenv("MTPROXY_HTTP_ADDR")) + if httpAddr == "" { + httpAddr = ":8080" + } + timeoutStr := strings.TrimSpace(os.Getenv("MTPROXY_CHECK_TIMEOUT")) + if timeoutStr == "" { + timeoutStr = "15s" + } + checkTimeout, err := time.ParseDuration(timeoutStr) + 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" + } + 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) + } + allowedRaw := os.Getenv("MTPROXY_ALLOWED_IPS") + prefixes, err := allowlist.ParseCommaList(allowedRaw) + if err != nil { + return nil, err + } + return &config{ + listFile: listFile, + checkInterval: interval, + httpAddr: httpAddr, + checkTimeout: checkTimeout, + dcID: int16(dcParsed), + allowedPrefixes: prefixes, + }, nil +} + +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"` +} + +type snapshot struct { + CycleFinishedAt string `json:"cycle_finished_at"` + NextCheckAfter string `json:"next_check_after,omitempty"` + Proxies []proxyEntry `json:"proxies"` +} + +type store struct { + mu sync.RWMutex + data snapshot +} + +func (s *store) get() snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + out := s.data + out.Proxies = append([]proxyEntry(nil), s.data.Proxies...) + return out +} + +func (s *store) setCycle(entries []proxyEntry, finished time.Time, next time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Proxies = append([]proxyEntry(nil), entries...) + s.data.CycleFinishedAt = finished.UTC().Format(time.RFC3339) + if !next.IsZero() { + s.data.NextCheckAfter = next.UTC().Format(time.RFC3339) + } else { + s.data.NextCheckAfter = "" + } +} + +func readProxyLines(path string) ([]string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var lines []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lines = append(lines, line) + } + return lines, nil +} + +func checkOneLine(ctx context.Context, line string, dcID int16) proxyEntry { + now := time.Now().UTC().Format(time.RFC3339) + ent := proxyEntry{RawLine: line, CheckedAt: now} + t, err := parseurl.ParseTGProxy(line) + if err != nil { + ent.ExitCode = 2 + ent.ParseError = err.Error() + ent.Error = ent.ParseError + return ent + } + ent.URL = line + parsed, err := secret.Parse(t.Secret) + if err != nil { + ent.ExitCode = 2 + ent.ParseError = err.Error() + ent.Error = ent.ParseError + return ent + } + err = checker.Check(ctx, t.Host, t.Port, parsed, dcID) + code, msg := checkresult.Classify(err) + ent.ExitCode = code + ent.OK = err == nil + if msg != "" { + ent.Error = msg + } + return ent +} + +func runCycle(cfg *config, st *store) { + lines, err := readProxyLines(cfg.listFile) + if err != nil { + finished := time.Now().UTC() + st.setCycle([]proxyEntry{{ + RawLine: cfg.listFile, + ExitCode: 2, + ParseError: err.Error(), + Error: err.Error(), + CheckedAt: finished.Format(time.RFC3339), + }}, finished, time.Now().Add(cfg.checkInterval)) + log.Printf("read list file: %v", err) + return + } + entries := make([]proxyEntry, 0, len(lines)) + for _, line := range lines { + ctx, cancel := context.WithTimeout(context.Background(), cfg.checkTimeout) + ent := checkOneLine(ctx, line, cfg.dcID) + cancel() + entries = append(entries, ent) + } + finished := time.Now().UTC() + next := time.Now().Add(cfg.checkInterval) + st.setCycle(entries, finished, next) +} + +func whitelistMiddleware(prefixes []netip.Prefix, next http.Handler) http.Handler { + if len(prefixes) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' { + host = host[1 : len(host)-1] + } + addr, err := netip.ParseAddr(host) + if err != nil || !allowlist.Contains(prefixes, addr) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "forbidden"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func run() error { + cfg, err := loadConfig() + if err != nil { + return err + } + st := &store{} + + go func() { + runCycle(cfg, st) + t := time.NewTicker(cfg.checkInterval) + defer t.Stop() + for range t.C { + runCycle(cfg, st) + } + }() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/api/v1/proxies", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + snap := st.get() + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(snap); err != nil { + log.Printf("encode json: %v", err) + } + }) + + handler := whitelistMiddleware(cfg.allowedPrefixes, mux) + srv := &http.Server{ + Addr: cfg.httpAddr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + log.Printf("listening on %s, list=%s interval=%s", cfg.httpAddr, cfg.listFile, cfg.checkInterval) + errCh <- srv.ListenAndServe() + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + select { + case <-sig: + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(ctx) + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + } + return nil +} diff --git a/docker-compose.api.yaml b/docker-compose.api.yaml new file mode 100644 index 0000000..5e170a0 --- /dev/null +++ b/docker-compose.api.yaml @@ -0,0 +1,19 @@ +# HTTP API: список tg:// в файле, периодическая проверка, JSON по GET. +# Запуск: docker compose -f docker-compose.api.yaml up --build +services: + mtproxy-checkerd: + build: . + image: mtproxy_checker:local + entrypoint: ["/usr/local/bin/mtproxy_checkerd"] + ports: + - "8080:8080" + volumes: + - ./proxies.txt:/data/proxies.txt:ro + environment: + MTPROXY_LIST_FILE: /data/proxies.txt + MTPROXY_CHECK_INTERVAL: 5m + MTPROXY_HTTP_ADDR: ":8080" + MTPROXY_CHECK_TIMEOUT: 15s + MTPROXY_DC_ID: "2" + # Опционально: только перечисленные IP/CIDR (иначе не задавайте переменную) + # MTPROXY_ALLOWED_IPS: "172.17.0.0/16,127.0.0.1" diff --git a/docs/docker.ru.md b/docs/docker.ru.md index 3d24fad..ed5b863 100644 --- a/docs/docker.ru.md +++ b/docs/docker.ru.md @@ -5,12 +5,62 @@ | Компонент | Описание | |-----------|----------| | **База runtime** | Alpine Linux 3.20 | -| **Бинарник** | Статическая сборка Go (`CGO_ENABLED=0`), путь в контейнере: `/usr/local/bin/mtproxy_checker` | +| **Бинарники** | CLI: `/usr/local/bin/mtproxy_checker`. HTTP-сервис списка: `/usr/local/bin/mtproxy_checkerd` (см. ниже) | | **Сертификаты** | Пакет `ca-certificates` (для TLS к реальным хостам при необходимости) | -| **ENTRYPOINT** | Тот же бинарник — все аргументы `docker run …` передаются в утилиту | -| **Порты** | Ничего не слушает: только **исходящие** TCP до хоста и порта из ссылки | +| **ENTRYPOINT** | По умолчанию **CLI** — аргументы `docker run …` идут в `mtproxy_checker` | +| **Порты** | В режиме CLI ничего не слушает (только исходящий TCP). Образ объявляет **EXPOSE 8080** для режима API | -Одна команда `docker run` = **одна** проверка одного прокси; процесс завершается с **кодом выхода** (`0`…`4`), что удобно в CI и скриптах. +Одна команда `docker run` с entrypoint по умолчанию = **одна** проверка одного прокси; процесс завершается с **кодом выхода** (`0`…`4`), что удобно в CI и скриптах. + +## HTTP API: `mtproxy_checkerd` + +Лёгкий сервер на стандартной библиотеке Go: читает файл со списком `tg://` (как в разделе «файл — одна ссылка на строку»), **сразу после старта** выполняет первый цикл проверок, затем повторяет с интервалом. Результаты последнего цикла отдаются по HTTP в JSON. + +### Запуск контейнера (смена entrypoint) + +**Linux / macOS:** + +```bash +docker run --rm -p 8080:8080 \ + -v /path/to/proxies.txt:/data/proxies.txt:ro \ + --entrypoint /usr/local/bin/mtproxy_checkerd \ + mtproxy_checker:local +``` + +**Windows (PowerShell):** + +```powershell +docker run --rm -p 8080:8080 ` + -v "${PWD}\proxies.txt:/data/proxies.txt:ro" ` + --entrypoint /usr/local/bin/mtproxy_checkerd ` + mtproxy_checker:local +``` + +Готовый пример с переменными окружения: репозиторий **`docker-compose.api.yaml`** — `docker compose -f docker-compose.api.yaml up --build`. + +### Переменные окружения + +| Переменная | По умолчанию | Назначение | +|------------|--------------|------------| +| `MTPROXY_LIST_FILE` | `/data/proxies.txt` | Путь к файлу: одна `tg://` ссылка на строку; пустые строки и строки с `#` в начале пропускаются | +| `MTPROXY_CHECK_INTERVAL` | `5m` | Интервал между циклами (`time.ParseDuration`, например `5m`, `1h`) | +| `MTPROXY_HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP | +| `MTPROXY_CHECK_TIMEOUT` | `15s` | Таймаут одной проверки (аналог `-timeout` CLI) | +| `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` **не** используется | + +### HTTP + +| Метод | Путь | Ответ | +|--------|------|--------| +| 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, `1` ошибка проверки, `2` ошибка разбора URL/секрета, `3` прокси закрыл соединение, `4` таймаут), `error`, при необходимости `parse_error`, `checked_at`. + +### Whitelist IP и Docker + +Процесс видит **IP источника TCP** таким, каким его передал стек в контейнер. При пробросе порта с хоста (`-p 8080:8080`) на Linux часто это адрес **шлюза bridge** или **userland-proxy**, а не «настоящий» IP клиента с хоста. Имеет смысл задавать **CIDR подсети Docker** (например `172.17.0.0/16`), слушать только внутреннюю сеть, использовать **`--network host`** (на Linux) или ограничивать доступ на **обратном прокси** (nginx `allow` и т.п.). ## Требования diff --git a/internal/allowlist/allowlist.go b/internal/allowlist/allowlist.go new file mode 100644 index 0000000..63e9cbe --- /dev/null +++ b/internal/allowlist/allowlist.go @@ -0,0 +1,60 @@ +package allowlist + +import ( + "fmt" + "net/netip" + "strings" +) + +// ParseCommaList parses MTPROXY_ALLOWED_IPS: comma-separated IPv4/IPv6 addresses or CIDR prefixes. +// Empty or whitespace-only input returns (nil, nil) meaning no restriction. +func ParseCommaList(s string) ([]netip.Prefix, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + var out []netip.Prefix + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + pfx, err := parseEntry(part) + if err != nil { + return nil, fmt.Errorf("allowed_ips entry %q: %w", part, err) + } + out = append(out, pfx) + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func parseEntry(s string) (netip.Prefix, error) { + if strings.Contains(s, "/") { + return netip.ParsePrefix(s) + } + addr, err := netip.ParseAddr(s) + if err != nil { + return netip.Prefix{}, err + } + bits := 32 + if addr.Is6() { + bits = 128 + } + return addr.Prefix(bits) +} + +// Contains reports whether addr matches any prefix in list. Empty list means allow all. +func Contains(list []netip.Prefix, addr netip.Addr) bool { + if len(list) == 0 { + return true + } + for _, p := range list { + if p.Contains(addr) { + return true + } + } + return false +} diff --git a/internal/allowlist/allowlist_test.go b/internal/allowlist/allowlist_test.go new file mode 100644 index 0000000..f8e5e9b --- /dev/null +++ b/internal/allowlist/allowlist_test.go @@ -0,0 +1,32 @@ +package allowlist + +import ( + "net/netip" + "testing" +) + +func TestParseCommaList(t *testing.T) { + list, err := ParseCommaList(" ") + if err != nil || list != nil { + t.Fatalf("empty: list=%v err=%v", list, err) + } + list, err = ParseCommaList("192.168.1.1, 10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("want 2 prefixes, got %d", len(list)) + } + a := netip.MustParseAddr("10.5.5.5") + if !Contains(list, a) { + t.Fatal("10.5.5.5 should match 10.0.0.0/8") + } + b := netip.MustParseAddr("192.168.1.1") + if !Contains(list, b) { + t.Fatal("192.168.1.1 should match host /32") + } + c := netip.MustParseAddr("8.8.8.8") + if Contains(list, c) { + t.Fatal("8.8.8.8 should not match") + } +} diff --git a/internal/checkresult/checkresult.go b/internal/checkresult/checkresult.go new file mode 100644 index 0000000..90773dc --- /dev/null +++ b/internal/checkresult/checkresult.go @@ -0,0 +1,22 @@ +package checkresult + +import ( + "context" + "errors" + + "mtproxy_checker/internal/checker" +) + +// Classify maps a checker error to CLI-style exit codes: 0 OK, 1 generic, 3 proxy closed, 4 timeout. +func Classify(err error) (exitCode int, message string) { + if err == nil { + return 0, "" + } + if errors.Is(err, checker.ErrProxyClosed) { + return 3, err.Error() + } + if errors.Is(err, context.DeadlineExceeded) { + return 4, "timeout" + } + return 1, err.Error() +}