package netcheck import ( "context" "fmt" "net" "strings" "time" "vpnclient/internal/config" "vpnclient/internal/linknorm" "vpnclient/internal/protocols/hysteria2" ) // 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"` } // 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) } // 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) var host, port string 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 { err = probeUDP(ctx, host, port) } else { err = probeTCP(ctx, host, port) } if err != nil { res.Error = friendlyDialError(err, hy2) 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: 4 * 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: 4 * time.Second} conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port)) if err != nil { return err } defer conn.Close() deadline := time.Now().Add(1500 * time.Millisecond) if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { deadline = dl } _ = conn.SetDeadline(deadline) // Any datagram is enough to exercise the UDP path; QUIC rarely answers a bare probe. 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() { // No ICMP unreachable → port is typically open or filtered; treat as reachable for UI ping. return nil } 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) } }