Release 3.8.1.1: lighter idle UI poll, core cache, multicore ping.
Reduce idle CPU (cheap PollUI, binary cache, logbuf cap, DOM fingerprint), speed ping/AWG HostPort, CopyBuffer pool; ship arm64 build and update notes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+49
-47
@@ -1,7 +1,6 @@
|
||||
package apphost
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -18,6 +17,8 @@ import (
|
||||
"vpnclient/internal/appui"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/corebin"
|
||||
"vpnclient/internal/logbuf"
|
||||
"vpnclient/internal/netcheck"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
@@ -31,7 +32,7 @@ type App struct {
|
||||
mu sync.Mutex
|
||||
Mgr *core.Manager
|
||||
CfgPath string
|
||||
LogBuf *bytes.Buffer
|
||||
LogBuf *logbuf.Buffer
|
||||
UpdateStatus update.Status
|
||||
Pings []netcheck.Result
|
||||
OpenURL func(string) error
|
||||
@@ -67,7 +68,7 @@ type PingBestResult struct {
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
||||
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
|
||||
return &App{
|
||||
Mgr: mgr,
|
||||
CfgPath: cfgPath,
|
||||
@@ -89,71 +90,53 @@ func (a *App) GetEditState() (UIState, error) {
|
||||
}
|
||||
|
||||
func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||
// Hold App.mu only for fields mutated by update/ping handlers.
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
upd := a.UpdateStatus
|
||||
pings := append([]netcheck.Result(nil), a.Pings...)
|
||||
cfgPath := a.CfgPath
|
||||
a.mu.Unlock()
|
||||
|
||||
st := a.Mgr.Status()
|
||||
cfg := a.Mgr.Config()
|
||||
proxy := ""
|
||||
active := cfg.Active
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
proxy = p.Proxy
|
||||
active = p.Name
|
||||
}
|
||||
poll := a.Mgr.PollUI(includeSecrets)
|
||||
st := poll.Status
|
||||
proxy := poll.ActiveProxy
|
||||
if !includeSecrets {
|
||||
proxy = config.RedactProxyURI(proxy)
|
||||
}
|
||||
corePath := ""
|
||||
coreReady := false
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
switch p.Protocol {
|
||||
case config.ProtocolHysteria2:
|
||||
if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
case config.ProtocolAWG:
|
||||
if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||
if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
default:
|
||||
if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
}
|
||||
} else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
||||
corePath, coreReady = path, true
|
||||
}
|
||||
hy2 := a.Mgr.ActiveHy2Options()
|
||||
hy2 := poll.Hy2
|
||||
if !includeSecrets {
|
||||
hy2.ObfsPassword = ""
|
||||
}
|
||||
|
||||
corePath, coreReady := resolveCoreCached(poll.BinDir, poll.ActiveProtocol)
|
||||
|
||||
profiles := poll.Profiles
|
||||
if includeSecrets {
|
||||
// Editor needs state.Proxy; list still omits other profiles' URIs.
|
||||
profiles = config.RedactProfileList(profiles)
|
||||
}
|
||||
|
||||
out := UIState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
ActiveProfile: active,
|
||||
ActiveProfile: poll.Active,
|
||||
Protocol: string(st.Protocol),
|
||||
HTTPProxy: st.HTTPProxy,
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: cfg.SystemProxy,
|
||||
SystemProxy: poll.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreReady,
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.CfgPath,
|
||||
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
||||
ConfigPath: cfgPath,
|
||||
Profiles: profiles,
|
||||
Version: update.DisplayVersion(),
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
Update: upd,
|
||||
Pings: pings,
|
||||
Subscription: poll.Subscription,
|
||||
Hy2: hy2,
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
out.Protocol = string(p.Protocol)
|
||||
}
|
||||
out.Protocol = string(poll.ActiveProtocol)
|
||||
}
|
||||
if st.Connected {
|
||||
out.SystemProxy = st.SystemProxy
|
||||
@@ -161,6 +144,25 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
|
||||
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
|
||||
var err error
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
path, err = corebin.Resolve(key, func() (string, error) { return hysteria2.ResolveBinary(binDir) })
|
||||
case config.ProtocolAWG:
|
||||
path, err = corebin.Resolve(key, func() (string, error) { return awg.ResolveBinary(binDir) })
|
||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||
path, err = corebin.Resolve(key, func() (string, error) { return xray.ResolveBinary(binDir) })
|
||||
default:
|
||||
path, err = corebin.Resolve(key, func() (string, error) { return naive.ResolveBinary(binDir) })
|
||||
}
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
+22
-2
@@ -202,6 +202,22 @@
|
||||
(pings || []).forEach((p) => { pingMap[p.name] = p; });
|
||||
}
|
||||
|
||||
let listFP = "";
|
||||
|
||||
function profilesFingerprint(state) {
|
||||
const parts = [
|
||||
state.connected ? "1" : "0",
|
||||
state.active_profile || state.profile || "",
|
||||
];
|
||||
(state.profiles || []).forEach((p) => {
|
||||
parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || ""));
|
||||
});
|
||||
(state.pings || []).forEach((p) => {
|
||||
parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
|
||||
});
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function fillProfiles(list, active) {
|
||||
profiles = list || [];
|
||||
const cur = profile.value;
|
||||
@@ -361,7 +377,11 @@
|
||||
btn.disabled = busy;
|
||||
|
||||
rememberPings(state.pings || []);
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
const fp = profilesFingerprint(state);
|
||||
if (syncForm || fp !== listFP) {
|
||||
listFP = fp;
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
}
|
||||
renderUpdate(state.update, state.version);
|
||||
if (typeof state.subscription_url === "string" && !dirty) {
|
||||
subUrl.value = state.subscription_url;
|
||||
@@ -641,5 +661,5 @@
|
||||
setMeta(String(e), "err");
|
||||
}
|
||||
})();
|
||||
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
|
||||
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="brand-wrap">
|
||||
<div class="brand-row">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<span class="badge-ver" id="badgeVer">3.8.0</span>
|
||||
<span class="badge-ver" id="badgeVer">3.8.1</span>
|
||||
</div>
|
||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,20 @@ func (c *Config) ListProfiles() []ProfileInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// ListProfilesForPoll is like ListProfiles but omits Proxy URIs (cheaper idle polls).
|
||||
func (c *Config) ListProfilesForPoll() []ProfileInfo {
|
||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||
for _, p := range c.Profiles {
|
||||
out = append(out, ProfileInfo{
|
||||
Name: p.Name,
|
||||
Protocol: string(p.Protocol),
|
||||
Host: proxyHost(p.Proxy),
|
||||
Active: p.Name == c.Active,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RedactProfileList clears Proxy URIs from a profile list (host/protocol remain).
|
||||
// Use for periodic UI polls so credentials are not echoed for every profile.
|
||||
func RedactProfileList(in []ProfileInfo) []ProfileInfo {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/corebin"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
@@ -277,6 +278,76 @@ func (m *Manager) Config() *config.Config {
|
||||
return m.cfg.Clone()
|
||||
}
|
||||
|
||||
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
|
||||
type UIPoll struct {
|
||||
Status Status
|
||||
Active string
|
||||
SystemProxy bool
|
||||
Subscription string
|
||||
ActiveProxy string
|
||||
ActiveProtocol config.Protocol
|
||||
Profiles []config.ProfileInfo
|
||||
Hy2 Hy2Options
|
||||
BinDir string
|
||||
}
|
||||
|
||||
// PollUI gathers status + profile list under one lock without Config().Clone().
|
||||
func (m *Manager) PollUI(includeSecrets bool) UIPoll {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
out := UIPoll{
|
||||
Status: Status{SystemProxy: m.sys.Enabled()},
|
||||
BinDir: m.binDir,
|
||||
SystemProxy: false,
|
||||
Subscription: "",
|
||||
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
|
||||
}
|
||||
if m.cfg != nil {
|
||||
out.Active = m.cfg.Active
|
||||
out.SystemProxy = m.cfg.SystemProxy
|
||||
out.Subscription = m.cfg.SubscriptionURL
|
||||
if includeSecrets {
|
||||
out.Profiles = m.cfg.ListProfiles()
|
||||
} else {
|
||||
out.Profiles = m.cfg.ListProfilesForPoll()
|
||||
}
|
||||
if p, err := m.cfg.ActiveProfile(); err == nil {
|
||||
out.Active = p.Name
|
||||
out.ActiveProxy = p.Proxy
|
||||
out.ActiveProtocol = p.Protocol
|
||||
out.Hy2 = Hy2Options{
|
||||
Congestion: p.Hy2Congestion,
|
||||
BBRProfile: p.Hy2BBRProfile,
|
||||
BandwidthUp: p.Hy2BandwidthUp,
|
||||
BandwidthDown: p.Hy2BandwidthDown,
|
||||
Obfs: p.Hy2Obfs,
|
||||
ObfsPassword: p.Hy2ObfsPassword,
|
||||
SNI: p.Hy2SNI,
|
||||
Insecure: p.Hy2Insecure,
|
||||
PinSHA256: p.Hy2PinSHA256,
|
||||
FastOpen: p.Hy2FastOpen,
|
||||
Lazy: p.Hy2Lazy,
|
||||
HopInterval: p.Hy2HopInterval,
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
out.Status.Connected = true
|
||||
if m.profile != nil {
|
||||
out.Status.Profile = m.profile.Name
|
||||
out.Status.Protocol = m.profile.Protocol
|
||||
}
|
||||
if hp, ok := m.engine.LocalHTTPProxy(); ok {
|
||||
out.Status.HTTPProxy = hp
|
||||
}
|
||||
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
|
||||
out.Status.SOCKSProxy = sp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -572,24 +643,37 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var (
|
||||
path string
|
||||
err error
|
||||
)
|
||||
switch proto {
|
||||
case config.ProtocolHysteria2:
|
||||
return hysteria2.EnsureBinary(binDir)
|
||||
path, err = hysteria2.EnsureBinary(binDir)
|
||||
case config.ProtocolAWG:
|
||||
return awg.EnsureBinary(binDir)
|
||||
path, err = awg.EnsureBinary(binDir)
|
||||
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||
return xray.EnsureBinary(binDir)
|
||||
path, err = xray.EnsureBinary(binDir)
|
||||
default:
|
||||
return naive.EnsureBinary(binDir)
|
||||
path, err = naive.EnsureBinary(binDir)
|
||||
proto = config.ProtocolNaive
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
corebin.Set(corebin.CacheKey(binDir, corebin.ProtoKey(string(proto))), path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
|
||||
// Downloads run in parallel (one goroutine per core) to use multiple CPU cores / network.
|
||||
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
m.mu.Lock()
|
||||
binDir := m.binDir
|
||||
m.mu.Unlock()
|
||||
|
||||
corebin.Invalidate()
|
||||
|
||||
type item struct {
|
||||
key string
|
||||
path string
|
||||
@@ -623,6 +707,7 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||
continue
|
||||
}
|
||||
out[it.key] = it.path
|
||||
corebin.Set(corebin.CacheKey(binDir, it.key), it.path)
|
||||
}
|
||||
return out, first
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package corebin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = map[string]string{}
|
||||
)
|
||||
|
||||
// CacheKey identifies a resolved core binary for a binDir + protocol.
|
||||
func CacheKey(binDir, proto string) string {
|
||||
return binDir + "\x00" + proto
|
||||
}
|
||||
|
||||
// Get returns a cached absolute path if present.
|
||||
func Get(key string) (string, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
p, ok := cache[key]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Set stores a resolved path.
|
||||
func Set(key, path string) {
|
||||
if key == "" || path == "" {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
cache[key] = path
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Invalidate clears all cached paths (call after EnsureCore / binDir change).
|
||||
func Invalidate() {
|
||||
mu.Lock()
|
||||
cache = map[string]string{}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed.
|
||||
func Resolve(key string, fn func() (string, error)) (string, error) {
|
||||
if path, ok := Get(key); ok {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
mu.Lock()
|
||||
delete(cache, key)
|
||||
mu.Unlock()
|
||||
}
|
||||
path, err := fn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
Set(key, path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
|
||||
func ProtoKey(proto string) string {
|
||||
switch proto {
|
||||
case "hysteria2":
|
||||
return "hysteria2"
|
||||
case "awg":
|
||||
return "awg"
|
||||
case "vless", "vmess", "trojan", "xray":
|
||||
return "xray"
|
||||
default:
|
||||
return "naive"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package corebin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCacheAndStale(t *testing.T) {
|
||||
Invalidate()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "core")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := CacheKey(dir, "naive")
|
||||
calls := 0
|
||||
fn := func() (string, error) {
|
||||
calls++
|
||||
return path, nil
|
||||
}
|
||||
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
|
||||
t.Fatalf("first: path=%q calls=%d err=%v", p, calls, err)
|
||||
}
|
||||
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
|
||||
t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
if _, err := Resolve(key, fn); err != nil {
|
||||
// fn returns removed path; Resolve still succeeds but next Stat misses
|
||||
t.Fatalf("unexpected err after delete before recreate: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected refresh call, got %d", calls)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("y"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := Resolve(key, fn); err != nil || p != path {
|
||||
t.Fatalf("after recreate: %q %v", p, err)
|
||||
}
|
||||
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" {
|
||||
t.Fatal("ProtoKey")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const DefaultMax = 256 << 10 // 256 KiB
|
||||
|
||||
// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
|
||||
type Buffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func New(max int) *Buffer {
|
||||
if max <= 0 {
|
||||
max = DefaultMax
|
||||
}
|
||||
return &Buffer{max: max}
|
||||
}
|
||||
|
||||
func (b *Buffer) Write(p []byte) (int, error) {
|
||||
if b == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.buf.Len()+len(p) > b.max {
|
||||
// Drop oldest half to keep recent diagnostics.
|
||||
keep := b.buf.Bytes()
|
||||
if len(keep) > b.max/2 {
|
||||
keep = keep[len(keep)-b.max/2:]
|
||||
}
|
||||
b.buf.Reset()
|
||||
_, _ = b.buf.Write(keep)
|
||||
_, _ = b.buf.WriteString("\n… log truncated …\n")
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *Buffer) String() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func (b *Buffer) Len() int {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Len()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBufferConcurrentWriteAndCap(t *testing.T) {
|
||||
b := New(64)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 40; j++ {
|
||||
_, _ = b.Write([]byte("abcdefghij"))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if b.Len() > 64+40 { // allow truncate marker overhead
|
||||
t.Fatalf("len=%d exceeds soft cap", b.Len())
|
||||
}
|
||||
s := b.String()
|
||||
if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
|
||||
t.Fatalf("unexpected content %q", s)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,7 +45,14 @@ func PingAll(ctx context.Context, targets []Target) []Result {
|
||||
if len(targets) == 0 {
|
||||
return out
|
||||
}
|
||||
workers := 12
|
||||
// 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)
|
||||
}
|
||||
@@ -99,7 +107,8 @@ func BestOK(results []Result) (Result, bool) {
|
||||
}
|
||||
|
||||
// PingProfile pings using protocol hints when available.
|
||||
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
|
||||
// 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) == "" {
|
||||
@@ -107,9 +116,22 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
||||
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 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)
|
||||
|
||||
@@ -454,7 +454,11 @@ func (c *Conf) validate() error {
|
||||
}
|
||||
|
||||
// HostPort returns UDP endpoint host and port for ping.
|
||||
// Prefers a light Endpoint=/URI extract; falls back to full Parse for JSON/vpn:// blobs.
|
||||
func HostPort(raw string) (host, port string, err error) {
|
||||
if h, p, ok := lightEndpoint(raw); ok {
|
||||
return h, p, nil
|
||||
}
|
||||
c, err := Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -466,6 +470,67 @@ func HostPort(raw string) (host, port string, err error) {
|
||||
return h, p, nil
|
||||
}
|
||||
|
||||
func lightEndpoint(raw string) (host, port string, ok bool) {
|
||||
raw = sanitizeShare(raw)
|
||||
if raw == "" {
|
||||
return "", "", false
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
|
||||
strings.HasPrefix(lower, "wireguard://"):
|
||||
// URI host:port only — no full config decode.
|
||||
rest := raw
|
||||
if i := strings.Index(rest, "://"); i >= 0 {
|
||||
rest = rest[i+3:]
|
||||
}
|
||||
if at := strings.Index(rest, "@"); at >= 0 {
|
||||
rest = rest[at+1:]
|
||||
}
|
||||
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
h, p, err := net.SplitHostPort(rest)
|
||||
if err != nil {
|
||||
return rest, "51820", true
|
||||
}
|
||||
return h, p, true
|
||||
}
|
||||
if strings.Contains(lower, "endpoint") {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(line)
|
||||
if !strings.HasPrefix(low, "endpoint") {
|
||||
continue
|
||||
}
|
||||
_, val, cutOK := strings.Cut(line, "=")
|
||||
if !cutOK {
|
||||
_, val, cutOK = strings.Cut(line, ":")
|
||||
}
|
||||
if !cutOK {
|
||||
continue
|
||||
}
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
h, p, err := net.SplitHostPort(val)
|
||||
if err != nil {
|
||||
return val, "51820", true
|
||||
}
|
||||
return h, p, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// lookupIP is overridable in tests.
|
||||
var lookupIP = defaultLookupIP
|
||||
|
||||
|
||||
@@ -236,3 +236,29 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestHostPortLightINI(t *testing.T) {
|
||||
raw := `[Interface]
|
||||
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
Address = 10.8.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
|
||||
Endpoint = 203.0.113.10:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
`
|
||||
h, p, err := HostPort(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h != "203.0.113.10" || p != "51820" {
|
||||
t.Fatalf("got %s:%s", h, p)
|
||||
}
|
||||
h2, p2, err := HostPort("awg://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=@9.9.9.9:41421")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h2 != "9.9.9.9" || p2 != "41421" {
|
||||
t.Fatalf("uri got %s:%s", h2, p2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,17 +268,24 @@ func handleHTTPProxy(client net.Conn, dial DialFunc) error {
|
||||
|
||||
func relay(a, b net.Conn) error {
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(a, b)
|
||||
copyOne := func(dst, src net.Conn) {
|
||||
bufp := copyBufPool.Get().(*[]byte)
|
||||
defer copyBufPool.Put(bufp)
|
||||
_, err := io.CopyBuffer(dst, src, *bufp)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(b, a)
|
||||
errc <- err
|
||||
}()
|
||||
}
|
||||
go copyOne(a, b)
|
||||
go copyOne(b, a)
|
||||
err := <-errc
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
<-errc
|
||||
return err
|
||||
}
|
||||
|
||||
var copyBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make([]byte, 32*1024)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
|
||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||
// Keep major.minor.patch only — no build suffix here.
|
||||
const CurrentVersion = "3.8.0"
|
||||
const CurrentVersion = "3.8.1"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
const BuildNumber = 3
|
||||
const BuildNumber = 1
|
||||
|
||||
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
||||
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||
|
||||
Reference in New Issue
Block a user