package netcheck import ( "context" "fmt" "net" "sort" "strings" "sync" "time" "vpnclient/internal/config" "vpnclient/internal/linknorm" "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/xray" ) // Result is a reachability measurement to a proxy host. type Result struct { Name string `json:"name"` Host string `json:"host"` Port string `json:"port"` Ms int64 `json:"ms"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } // Target describes one profile to ping. type Target struct { Name string Protocol config.Protocol Proxy string } // PingProxyURI measures connect time to the host in a share/proxy URI. func PingProxyURI(ctx context.Context, name, proxyURI string) Result { return PingProfile(ctx, name, "", proxyURI) } // PingAll pings targets in parallel (bounded concurrency) and sorts OK results by latency. func PingAll(ctx context.Context, targets []Target) []Result { out := make([]Result, len(targets)) if len(targets) == 0 { return out } workers := 12 if len(targets) < workers { workers = len(targets) } sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, t := range targets { wg.Add(1) go func(i int, t Target) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() if strings.TrimSpace(t.Proxy) == "" { out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} return } out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) }(i, t) } wg.Wait() sorted := append([]Result(nil), out...) sort.SliceStable(sorted, func(i, j int) bool { a, b := sorted[i], sorted[j] if a.OK != b.OK { return a.OK } if !a.OK { return a.Name < b.Name } if a.Ms != b.Ms { return a.Ms < b.Ms } return a.Name < b.Name }) return sorted } // BestOK returns the lowest-latency successful result, if any. func BestOK(results []Result) (Result, bool) { var best Result found := false for _, r := range results { if !r.OK { continue } if !found || r.Ms < best.Ms { best = r found = true } } return best, found } // PingProfile pings using protocol hints when available. // Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused". func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result { res := Result{Name: name} if strings.TrimSpace(proxyURI) == "" { res.Error = "нет ссылки сервера" return res } hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI) isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI) var host, port string if isAWG { h, p, err := awg.HostPort(proxyURI) if err != nil { res.Error = err.Error() return res } host, port = h, p } else if isXray { h, p, err := xray.HostPort(proxyURI) if err != nil { res.Error = err.Error() return res } host, port = h, p } else if hy2 { h, p, err := hysteria2.HostPort(proxyURI) if err != nil { res.Error = err.Error() return res } host, port = h, p } else { normalized, _, _, err := linknorm.Normalize(proto, proxyURI) if err != nil { res.Error = err.Error() return res } rest := normalized if i := strings.Index(rest, "://"); i >= 0 { rest = rest[i+3:] } if at := strings.LastIndex(rest, "@"); at >= 0 { rest = rest[at+1:] } if i := strings.IndexAny(rest, "/?#"); i >= 0 { rest = rest[:i] } host = rest port = "443" if i := strings.LastIndex(rest, ":"); i >= 0 { host = rest[:i] port = rest[i+1:] } } res.Host = host res.Port = port if host == "" { res.Error = "нет хоста" return res } start := time.Now() var err error if hy2 || isAWG { err = probeUDP(ctx, host, port) } else { err = probeTCP(ctx, host, port) } if err != nil { res.Error = friendlyDialError(err, hy2 || isAWG) return res } res.Ms = time.Since(start).Milliseconds() res.OK = true return res } func probeTCP(ctx context.Context, host, port string) error { d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) if err != nil { return err } _ = conn.Close() return nil } func probeUDP(ctx context.Context, host, port string) error { d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port)) if err != nil { return err } defer conn.Close() deadline := time.Now().Add(1200 * time.Millisecond) if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { deadline = dl } _ = conn.SetDeadline(deadline) if _, err := conn.Write([]byte{0}); err != nil { return err } buf := make([]byte, 64) _, err = conn.Read(buf) if err == nil { return nil } if ne, ok := err.(net.Error); ok && ne.Timeout() { // UDP is connectionless: no reply does NOT mean the port is open. return fmt.Errorf("udp timeout") } return err } func friendlyDialError(err error, hy2 bool) string { msg := err.Error() lower := strings.ToLower(msg) switch { case strings.Contains(lower, "refused"): if hy2 { return "UDP порт недоступен (connection refused)" } return "порт закрыт (connection refused)" case strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"): return "таймаут" case strings.Contains(lower, "no such host"): return "DNS: хост не найден" default: return fmt.Sprintf("недоступен: %v", err) } }