257 lines
6.1 KiB
Go
257 lines
6.1 KiB
Go
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
|
|
}
|
|
|
|
var (
|
|
rtt time.Duration
|
|
err error
|
|
)
|
|
if hy2 || isAWG {
|
|
rtt, 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
|
|
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 → up (RTT to reply)
|
|
// - short read-timeout after a successful write → soft-up (RTT ≈ DNS+dial+write)
|
|
func probeUDP(ctx context.Context, host, port string) (time.Duration, 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, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Brief window only to catch ICMP port-unreachable; do not wait for an app reply.
|
|
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, err
|
|
}
|
|
afterWrite := time.Now()
|
|
|
|
buf := make([]byte, 64)
|
|
_, err = conn.Read(buf)
|
|
if err == nil {
|
|
return time.Since(start), nil
|
|
}
|
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
|
// No ICMP refuse → endpoint is plausible; latency without the wait pad.
|
|
return afterWrite.Sub(start), nil
|
|
}
|
|
return 0, 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)
|
|
}
|
|
}
|