package netcheck import ( "context" "fmt" "net" "runtime" "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"` Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse) 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 } // Scale with available OS threads (GOMAXPROCS); keep a useful floor/ceiling. workers := runtime.GOMAXPROCS(0) * 2 if workers < 8 { workers = 8 } if workers > 32 { workers = 32 } 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 hard OK result (excludes UDP soft-up). // Soft-up nodes stay visible in the list but are not chosen as «Лучший». func BestOK(results []Result) (Result, bool) { var best Result found := false for _, r := range results { if !r.OK || r.Soft { 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 / AWG use UDP — TCP would always look "refused". // When proto is set, Detect is skipped (trust stored protocol). 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 } var hy2, isAWG, isXray bool switch proto { case config.ProtocolHysteria2: hy2 = true case config.ProtocolAWG: isAWG = true case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: isXray = true case config.ProtocolNaive: // TCP host parse below default: hy2 = hysteria2.Detect(proxyURI) isAWG = awg.Detect(proxyURI) isXray = 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 } var ( rtt time.Duration soft bool err error ) if hy2 || isAWG { rtt, soft, err = probeUDP(ctx, host, port) } else { rtt, err = probeTCP(ctx, host, port) } if err != nil { res.Error = friendlyDialError(err, hy2 || isAWG) return res } ms := rtt.Milliseconds() if ms < 1 { ms = 1 } res.Ms = ms res.OK = true res.Soft = soft return res } func probeTCP(ctx context.Context, host, port string) (time.Duration, error) { start := time.Now() d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) if err != nil { return 0, err } _ = conn.Close() return time.Since(start), nil } // probeUDP checks Hy2/AWG endpoints. // // UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout // as failure marked live nodes as down. Semantics: // - ICMP / connection refused → down // - any reply → hard up (RTT to reply) // - short read-timeout after a successful write → soft-up (display only; not for Best) func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) { start := time.Now() d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port)) if err != nil { return 0, false, err } defer conn.Close() icmpWait := 350 * time.Millisecond deadline := time.Now().Add(icmpWait) if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { deadline = dl } _ = conn.SetDeadline(deadline) if _, err := conn.Write([]byte{0}); err != nil { return 0, false, err } afterWrite := time.Now() buf := make([]byte, 64) _, err = conn.Read(buf) if err == nil { return time.Since(start), false, nil } if ne, ok := err.(net.Error); ok && ne.Timeout() { return afterWrite.Sub(start), true, nil } return 0, false, err } func friendlyDialError(err error, udp bool) string { msg := err.Error() lower := strings.ToLower(msg) switch { case strings.Contains(lower, "refused"): if udp { 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) } }