Release 2.2.0: Happ-style multi-node list with ping and auto best connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-29 07:39:46 +03:00
co-authored by Cursor
parent 37e31ae742
commit 8c513f6e08
17 changed files with 527 additions and 99 deletions
+72 -5
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net"
"sort"
"strings"
"sync"
"time"
"vpnclient/internal/config"
@@ -24,11 +26,78 @@ type Result struct {
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 {
@@ -111,7 +180,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
}
func probeTCP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * time.Second}
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return err
@@ -121,20 +190,19 @@ func probeTCP(ctx context.Context, host, port string) error {
}
func probeUDP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * time.Second}
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(1500 * time.Millisecond)
deadline := time.Now().Add(1200 * 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
}
@@ -144,7 +212,6 @@ func probeUDP(ctx context.Context, host, port string) error {
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