diff --git a/README.md b/README.md index c3ad878..e25f3e1 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,13 @@ https://evilfox.win/ Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. +В 3.8.1: +- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; +- список серверов в UI не пересобирается без изменений; интервал опроса 4 с; +- быстрее и точнее пинг (учитывается протокол профиля; лёгкий разбор AWG Endpoint); +- AWG-прокси копирует трафик через буферный pool; PingAll и установка cores используют несколько ядер CPU; +- 3.8.1+1: первая сборка линейки 3.8.1. + В 3.8.0: - единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики); - безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy); diff --git a/build-macos.bat b/build-macos.bat index efa883c..ad3771e 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1 go build -o "tools\packmac\packmac.exe" .\tools\packmac if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.0 -build 3.8.0.3 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.1 -build 3.8.1.1 -arch arm64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.0 -build 3.8.0.3 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.1 -build 3.8.1.1 -arch amd64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.0 -build 3.8.0.3 -arch universal +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.1 -build 3.8.1.1 -arch universal if errorlevel 1 exit /b 1 echo Built Mac GUI + CLI: diff --git a/cmd/vpnapp/main_darwin.go b/cmd/vpnapp/main_darwin.go index 50d0ccc..6a5e091 100644 --- a/cmd/vpnapp/main_darwin.go +++ b/cmd/vpnapp/main_darwin.go @@ -3,13 +3,13 @@ package main import ( - "bytes" "fmt" "log" "net/http" "os" "os/exec" "os/signal" + "runtime" "syscall" "time" @@ -18,6 +18,7 @@ import ( "vpnclient/internal/apphost" "vpnclient/internal/config" "vpnclient/internal/core" + "vpnclient/internal/logbuf" "vpnclient/internal/update" ) @@ -27,6 +28,7 @@ func main() { } update.CleanupStaleDownloads() log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU()) cfgPath, err := config.LocateConfig() if err != nil { @@ -41,7 +43,7 @@ func main() { if err != nil { fatalf("Ошибка конфига %s: %v", cfgPath, err) } - logBuf := &bytes.Buffer{} + logBuf := logbuf.New(0) mgr, err := core.NewManager(cfgPath, cfg, logBuf) if err != nil { fatalf("%v", err) diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index 30ce66f..ffa0cb7 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -3,11 +3,11 @@ package main import ( - "bytes" "fmt" "log" "os" "path/filepath" + "runtime" "time" "unsafe" @@ -18,6 +18,7 @@ import ( "vpnclient/internal/appui" "vpnclient/internal/config" "vpnclient/internal/core" + "vpnclient/internal/logbuf" "vpnclient/internal/update" ) @@ -28,6 +29,7 @@ func main() { update.CleanupStaleDownloads() log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU()) cfgPath, err := config.LocateConfig() if err != nil { @@ -44,7 +46,7 @@ func main() { fatalDialog("Ошибка конфига %s: %v", cfgPath, err) } - logBuf := &bytes.Buffer{} + logBuf := logbuf.New(0) mgr, err := core.NewManager(cfgPath, cfg, logBuf) if err != nil { fatalDialog("%v", err) diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis index 272f4ab..9627cd8 100755 Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ diff --git a/dist/navis-release/darwin-arm64/Navis-cli b/dist/navis-release/darwin-arm64/Navis-cli index 2ebd472..cbe97d3 100755 Binary files a/dist/navis-release/darwin-arm64/Navis-cli and b/dist/navis-release/darwin-arm64/Navis-cli differ diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip index 05f0dc7..769c57c 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg index 59a595b..717bd22 100644 Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ diff --git a/dist/navis-release/darwin-arm64/Navis.version b/dist/navis-release/darwin-arm64/Navis.version index 1346430..e9781d3 100644 --- a/dist/navis-release/darwin-arm64/Navis.version +++ b/dist/navis-release/darwin-arm64/Navis.version @@ -1 +1 @@ -3.8.0+3 +3.8.1+1 diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION index e63c5be..d37c27a 100644 --- a/dist/navis-release/darwin-arm64/VERSION +++ b/dist/navis-release/darwin-arm64/VERSION @@ -1 +1 @@ -3.8.0.3 +3.8.1.1 diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 8817460..1e4818b 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,6 +1,6 @@ { - "version": "3.8.0", - "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.", + "version": "3.8.1", + "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,13 +16,13 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f", + "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", - "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4", - "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee" + "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05", + "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", diff --git a/dist/update.json b/dist/update.json index 8817460..1e4818b 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,6 +1,6 @@ { - "version": "3.8.0", - "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.", + "version": "3.8.1", + "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,13 +16,13 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f", + "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", - "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4", - "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee" + "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05", + "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", diff --git a/internal/apphost/app.go b/internal/apphost/app.go index faa38a2..e779194 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -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() diff --git a/internal/appui/app.js b/internal/appui/app.js index a6bf07c..f16876d 100644 --- a/internal/appui/app.js +++ b/internal/appui/app.js @@ -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); diff --git a/internal/appui/index.html b/internal/appui/index.html index 030769d..783722d 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -46,7 +46,7 @@

Navis

- 3.8.0 + 3.8.1

Быстрый клиент · Naive · Hy2 · AWG · Xray

diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 07e9926..6b7afc3 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -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 { diff --git a/internal/core/manager.go b/internal/core/manager.go index 074be4f..3732b15 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -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 } diff --git a/internal/corebin/cache.go b/internal/corebin/cache.go new file mode 100644 index 0000000..7984f9e --- /dev/null +++ b/internal/corebin/cache.go @@ -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" + } +} diff --git a/internal/corebin/cache_test.go b/internal/corebin/cache_test.go new file mode 100644 index 0000000..abf1e34 --- /dev/null +++ b/internal/corebin/cache_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/logbuf/buffer.go b/internal/logbuf/buffer.go new file mode 100644 index 0000000..c10c814 --- /dev/null +++ b/internal/logbuf/buffer.go @@ -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() +} diff --git a/internal/logbuf/buffer_test.go b/internal/logbuf/buffer_test.go new file mode 100644 index 0000000..c04322b --- /dev/null +++ b/internal/logbuf/buffer_test.go @@ -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) + } +} diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 60cbb74..96540fa 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -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) diff --git a/internal/protocols/awg/conf.go b/internal/protocols/awg/conf.go index 2fda8d8..f3feeea 100644 --- a/internal/protocols/awg/conf.go +++ b/internal/protocols/awg/conf.go @@ -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 diff --git a/internal/protocols/awg/conf_test.go b/internal/protocols/awg/conf_test.go index ad25021..c9f597e 100644 --- a/internal/protocols/awg/conf_test.go +++ b/internal/protocols/awg/conf_test.go @@ -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) + } +} diff --git a/internal/protocols/awg/proxy.go b/internal/protocols/awg/proxy.go index 69c8098..69416e3 100644 --- a/internal/protocols/awg/proxy.go +++ b/internal/protocols/awg/proxy.go @@ -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 + }, +} diff --git a/internal/update/update.go b/internal/update/update.go index d2cbe4d..25da83b 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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" diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh index 2022e5c..f974599 100755 --- a/scripts/build-macos-arm64.sh +++ b/scripts/build-macos-arm64.sh @@ -65,8 +65,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest() zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else "" dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else "" notes = ( - f"Navis {ver}+{build}: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); " - f"Mac .app zip update; EnsureCore lock; getState без секретов." + f"Navis {ver}+{build}: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, " + f"список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; " + f"параллельная работа на нескольких ядрах CPU." ) paths = [ Path('dist/update.json'), diff --git a/server/update.json b/server/update.json index 8817460..1e4818b 100644 --- a/server/update.json +++ b/server/update.json @@ -1,6 +1,6 @@ { - "version": "3.8.0", - "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.", + "version": "3.8.1", + "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,13 +16,13 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f", + "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip", - "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4", - "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee" + "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05", + "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", diff --git a/versioninfo.json b/versioninfo.json index b4b8007..dd1ef42 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -3,14 +3,14 @@ "FileVersion": { "Major": 3, "Minor": 8, - "Patch": 0, - "Build": 3 + "Patch": 1, + "Build": 1 }, "ProductVersion": { "Major": 3, "Minor": 8, - "Patch": 0, - "Build": 3 + "Patch": 1, + "Build": 1 }, "FileFlagsMask": "3f", "FileFlags": "00", @@ -21,12 +21,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "3.8.0.3", + "FileVersion": "3.8.1.1", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "3.8.0.3", + "ProductVersion": "3.8.1.1", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {