Files
navi/internal/netcheck/ping.go
T
M4andCursor 2ad376b49e
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00

287 lines
6.7 KiB
Go

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)
}
jobs := make(chan int, len(targets))
for i := range targets {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range jobs {
t := targets[i]
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
continue
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}
}()
}
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)
}
}