Files
mtproxy_checker/cmd/mtproxy_checkerd/main.go
T
Denozordec 6f2dbcbca3
Publish mtproxy_checker Docker image / test (push) Successful in 13s
Publish mtproxy_checker Docker image / build-and-push (push) Successful in 52s
Update MTPROXY_CHECK_TIMEOUT to 45s in docker-compose and documentation; adjust default timeout handling in code for improved performance under slow network conditions.
2026-04-11 13:39:52 +07:00

292 lines
7.4 KiB
Go

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 == "" {
// Fake-TLS + drain + req_pq/resPQ к DC часто >15s на медленных линиях и при многих прокси подряд.
timeoutStr = "45s"
}
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
}