diff --git a/README.md b/README.md index 4713ae5..737bb40 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,12 @@ https://evilfox.win/ Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. +В 2.7.3: +- восстановление системного прокси после аварийного завершения; +- auth-токен для локального macOS `/api`; +- обязательный SHA-256 при обновлении; +- исправлен ложный UDP ping; HTTPS-only подписки; UI не зависает на connect. + В 2.7.2: - убран вечный цикл обновления (проверка больше не устанавливает сама; только кнопка «Обновить»); - номер сборки в версии (`2.7.2+N` / FileVersion `2.7.2.N`); сравнение обновлений только по product semver. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8654969..0238a1b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,7 +14,7 @@ android { targetSdk = 35 // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build versionCode = 2_070_201 - versionName = "2.7.2+1" + versionName = "2.7.3+1" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index f747ffe..a04367e 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 2.7.2 -build 2.7.2.2 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.7.3 -build 2.7.3.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 2.7.2 -build 2.7.2.2 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.7.3 -build 2.7.3.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 2.7.2 -build 2.7.2.2 -arch universal +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.3 -build 2.7.3.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 6ba46b8..d5d4fba 100644 --- a/cmd/vpnapp/main_darwin.go +++ b/cmd/vpnapp/main_darwin.go @@ -58,10 +58,11 @@ func main() { }() } - ln, uiURL, err := apphost.ListenLocal() + ln, uiURL, token, err := apphost.ListenLocal() if err != nil { fatalf("listen: %v", err) } + a.APIToken = token srv := &http.Server{Handler: a.Handler()} go func() { _ = srv.Serve(ln) diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index 6c9a933..2bb902d 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -203,7 +203,7 @@ func (a *app) getState() (uiState, error) { CoreReady: coreReady, CorePath: corePath, ConfigPath: a.cfgPath, - Profiles: cfg.ListProfiles(), + Profiles: config.RedactProfileList(cfg.ListProfiles()), Version: update.DisplayVersion(), Update: a.updateStatus, Pings: append([]netcheck.Result(nil), a.pings...), @@ -263,30 +263,36 @@ func (a *app) connect() error { // If already connected to another profile, disconnects first. func (a *app) connectProfile(name string) error { a.mu.Lock() - defer a.mu.Unlock() - name = strings.TrimSpace(name) st := a.mgr.Status() if st.Connected { if name == "" || st.Profile == name { + a.mu.Unlock() return nil } + a.mu.Unlock() if err := a.mgr.Disconnect(); err != nil { return err } + a.mu.Lock() } if name != "" { if err := a.mgr.SetActiveProfile(name); err != nil { + a.mu.Unlock() return err } } if p, err := a.mgr.Config().ActiveProfile(); err != nil { + a.mu.Unlock() return err } else if strings.TrimSpace(p.Proxy) == "" { + a.mu.Unlock() return fmt.Errorf("сначала вставьте ссылку сервера") } + a.mu.Unlock() + // Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI. if _, err := a.mgr.EnsureCore(""); err != nil { return err } @@ -301,8 +307,6 @@ func (a *app) disconnect() error { } func (a *app) installCore() (string, error) { - a.mu.Lock() - defer a.mu.Unlock() paths, err := a.mgr.EnsureAllCores() if err != nil { return "", err @@ -352,32 +356,40 @@ func (a *app) pingBest(autoConnect bool) (pingBestResult, error) { out.BestMs = best.Ms a.mu.Lock() - defer a.mu.Unlock() st := a.mgr.Status() alreadyBest := st.Connected && st.Profile == best.Name if st.Connected && !alreadyBest { + a.mu.Unlock() if err := a.mgr.Disconnect(); err != nil { return out, err } + a.mu.Lock() } if !alreadyBest { if err := a.mgr.SetActiveProfile(best.Name); err != nil { + a.mu.Unlock() return out, err } } out.Selected = true if !autoConnect { + a.mu.Unlock() return out, nil } if alreadyBest { + a.mu.Unlock() out.Connected = true return out, nil } if p, err := a.mgr.Config().ActiveProfile(); err != nil { + a.mu.Unlock() return out, err } else if strings.TrimSpace(p.Proxy) == "" { + a.mu.Unlock() return out, fmt.Errorf("пустая ссылка у лучшего сервера") } + a.mu.Unlock() + if _, err := a.mgr.EnsureCore(""); err != nil { return out, err } diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis index 772d5e0..545b8c6 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 c910b5c..81c9fa3 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 7864ee8..63202cd 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 f191dfa..dad90d7 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 344de1f..2639e3f 100644 --- a/dist/navis-release/darwin-arm64/Navis.version +++ b/dist/navis-release/darwin-arm64/Navis.version @@ -1 +1 @@ -2.7.2+2 +2.7.3+1 diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION index d45bd54..b709f35 100644 --- a/dist/navis-release/darwin-arm64/VERSION +++ b/dist/navis-release/darwin-arm64/VERSION @@ -1 +1 @@ -2.7.2.2 +2.7.3.1 diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 5451bf0..86ac28e 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,6 +1,6 @@ { - "version": "2.7.2", - "notes": "Navis 2.7.2+2: иконка приложения (N), macOS AppIcon; обновление только по кнопке.", + "version": "2.7.3", + "notes": "2.7.3+1: восстановление system proxy после crash; auth для macOS /api; обязательный SHA обновлений; fix UDP ping; HTTPS-only подписки; UI без зависания на connect.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,7 +16,7 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "0a6d7297e132f9835e2b82f5f28ec1d4e2a51fc070343f8c701e019ac57a2e86", + "sha256": "847fe952e6d1dfb83bb74661accbbe2c6df0f4261a4c0619c0e20e0a7faae3ca", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", diff --git a/dist/update.json b/dist/update.json index 5451bf0..86ac28e 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,6 +1,6 @@ { - "version": "2.7.2", - "notes": "Navis 2.7.2+2: иконка приложения (N), macOS AppIcon; обновление только по кнопке.", + "version": "2.7.3", + "notes": "2.7.3+1: восстановление system proxy после crash; auth для macOS /api; обязательный SHA обновлений; fix UDP ping; HTTPS-only подписки; UI без зависания на connect.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,7 +16,7 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "0a6d7297e132f9835e2b82f5f28ec1d4e2a51fc070343f8c701e019ac57a2e86", + "sha256": "847fe952e6d1dfb83bb74661accbbe2c6df0f4261a4c0619c0e20e0a7faae3ca", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", diff --git a/internal/apphost/app.go b/internal/apphost/app.go index f33dcb9..7141991 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -3,6 +3,8 @@ package apphost import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -26,14 +28,15 @@ import ( // App is the GUI controller shared by Windows WebView and macOS HTTP UI. type App struct { - mu sync.Mutex - Mgr *core.Manager - CfgPath string - LogBuf *bytes.Buffer - UpdateStatus update.Status - Pings []netcheck.Result - OpenURL func(string) error + mu sync.Mutex + Mgr *core.Manager + CfgPath string + LogBuf *bytes.Buffer + UpdateStatus update.Status + Pings []netcheck.Result + OpenURL func(string) error OnAfterUpdate func() + APIToken string } type UIState struct { @@ -124,7 +127,7 @@ func (a *App) GetState() (UIState, error) { CoreReady: coreReady, CorePath: corePath, ConfigPath: a.CfgPath, - Profiles: cfg.ListProfiles(), + Profiles: config.RedactProfileList(cfg.ListProfiles()), Version: update.DisplayVersion(), Update: a.UpdateStatus, Pings: append([]netcheck.Result(nil), a.Pings...), @@ -180,28 +183,35 @@ func (a *App) Connect() error { return a.ConnectProfile("") } func (a *App) ConnectProfile(name string) error { a.mu.Lock() - defer a.mu.Unlock() - name = strings.TrimSpace(name) st := a.Mgr.Status() if st.Connected { if name == "" || st.Profile == name { + a.mu.Unlock() return nil } + a.mu.Unlock() if err := a.Mgr.Disconnect(); err != nil { return err } + a.mu.Lock() } if name != "" { if err := a.Mgr.SetActiveProfile(name); err != nil { + a.mu.Unlock() return err } } if p, err := a.Mgr.Config().ActiveProfile(); err != nil { + a.mu.Unlock() return err } else if strings.TrimSpace(p.Proxy) == "" { + a.mu.Unlock() return fmt.Errorf("сначала вставьте ссылку сервера") } + a.mu.Unlock() + + // Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI. if _, err := a.Mgr.EnsureCore(""); err != nil { return err } @@ -215,8 +225,6 @@ func (a *App) Disconnect() error { } func (a *App) InstallCore() (string, error) { - a.mu.Lock() - defer a.mu.Unlock() paths, err := a.Mgr.EnsureAllCores() if err != nil { return "", err @@ -256,32 +264,40 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) { out.BestMs = best.Ms a.mu.Lock() - defer a.mu.Unlock() st := a.Mgr.Status() alreadyBest := st.Connected && st.Profile == best.Name if st.Connected && !alreadyBest { + a.mu.Unlock() if err := a.Mgr.Disconnect(); err != nil { return out, err } + a.mu.Lock() } if !alreadyBest { if err := a.Mgr.SetActiveProfile(best.Name); err != nil { + a.mu.Unlock() return out, err } } out.Selected = true if !autoConnect { + a.mu.Unlock() return out, nil } if alreadyBest { + a.mu.Unlock() out.Connected = true return out, nil } if p, err := a.Mgr.Config().ActiveProfile(); err != nil { + a.mu.Unlock() return out, err } else if strings.TrimSpace(p.Proxy) == "" { + a.mu.Unlock() return out, fmt.Errorf("пустая ссылка у лучшего сервера") } + a.mu.Unlock() + if _, err := a.Mgr.EnsureCore(""); err != nil { return out, err } @@ -394,17 +410,38 @@ func (a *App) Handler() http.Handler { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.WriteString(w, appui.IndexHTML) + html := appui.IndexHTML + if a.APIToken != "" { + // Inject session token for the HTTP bridge (macOS). + html = strings.Replace(html, "", + "", 1) + } + _, _ = io.WriteString(w, html) }) mux.HandleFunc("/api/", a.handleAPI) return mux } +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } + if a.APIToken != "" { + tok := r.Header.Get("X-Navis-Token") + if tok == "" { + tok = r.URL.Query().Get("t") + } + if tok != a.APIToken { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } name := strings.TrimPrefix(r.URL.Path, "/api/") body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) var req struct { @@ -482,12 +519,25 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { } } -// ListenLocal binds 127.0.0.1:0 and returns listener + URL. -func ListenLocal() (net.Listener, string, error) { +// ListenLocal binds 127.0.0.1:0, generates an API token, and returns listener + URL. +func ListenLocal() (net.Listener, string, string, error) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - return nil, "", err + return nil, "", "", err + } + tok, err := randomToken(24) + if err != nil { + _ = ln.Close() + return nil, "", "", err } url := fmt.Sprintf("http://%s/", ln.Addr().String()) - return ln, url, nil + return ln, url, tok, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil } diff --git a/internal/appui/index.html b/internal/appui/index.html index 5e51d29..5edfc6e 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -740,7 +740,7 @@

Navis

- 2.7.2 + 2.7.3

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

@@ -915,11 +915,14 @@ if (typeof window.getState === "function") return; window.__navisHttp = true; async function call(name, args) { + const headers = { "Content-Type": "application/json" }; + if (window.__NAVIS_TOKEN__) headers["X-Navis-Token"] = window.__NAVIS_TOKEN__; const r = await fetch("/api/" + name, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: headers, body: JSON.stringify({ args: args || [] }) }); + if (r.status === 401) throw "unauthorized"; const j = await r.json(); if (j && j.error) throw j.error; return j ? j.result : null; @@ -1489,9 +1492,7 @@ (async () => { try { await refresh({ syncForm: true }); - if (profiles.length > 1 && autoBest.checked && !connected) { - await withBusy(async () => { await runBest(true); }); - } + // Auto-best connect is opt-in via checkbox only — never on first paint. } catch (e) { setMeta(String(e), "err"); } diff --git a/internal/config/config.go b/internal/config/config.go index 507fe84..14ec109 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,10 @@ package config import ( "encoding/json" "fmt" + "net" "os" "path/filepath" + "strings" ) // Protocol identifies a VPN/proxy backend. @@ -192,10 +194,47 @@ func (c *Config) Validate() error { default: return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol) } + for _, listen := range p.Listen { + if err := validateListenURI(listen); err != nil { + return fmt.Errorf("config: profile %q: %w", p.Name, err) + } + } } return nil } +func validateListenURI(uri string) error { + uri = strings.TrimSpace(uri) + if uri == "" { + return nil + } + var hostPort string + var ok bool + for _, scheme := range []string{"http", "socks", "socks5", "https"} { + if hostPort, ok = parseListenHostPort(uri, scheme); ok { + break + } + } + if !ok { + lower := strings.ToLower(uri) + if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") || strings.Contains(lower, "[::1]") { + return nil + } + return fmt.Errorf("listen %q must bind to loopback", uri) + } + host, _, err := net.SplitHostPort(hostPort) + if err != nil { + host = hostPort + } + host = strings.Trim(host, "[]") + switch strings.ToLower(host) { + case "127.0.0.1", "localhost", "::1", "": + return nil + default: + return fmt.Errorf("listen host %q is not loopback (only 127.0.0.1 / ::1)", host) + } +} + // ActiveProfile returns the selected profile. func (c *Config) ActiveProfile() (*Profile, error) { for i := range c.Profiles { diff --git a/internal/config/profiles.go b/internal/config/profiles.go index bcf4180..3eb01e7 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -29,6 +29,17 @@ func (c *Config) ListProfiles() []ProfileInfo { 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 { + out := make([]ProfileInfo, len(in)) + for i, p := range in { + out[i] = p + out[i].Proxy = "" + } + return out +} + func proxyHost(proxy string) string { proxy = strings.TrimSpace(proxy) if proxy == "" { diff --git a/internal/core/manager.go b/internal/core/manager.go index 67bbe5e..8d89f64 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -43,13 +43,29 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, if err != nil { return nil, err } - return &Manager{ + m := &Manager{ cfgPath: cfgPath, cfg: cfg, sys: sysproxy.New(), stderr: stderr, binDir: binDir, - }, nil + } + m.RecoverSystemProxy() + return m, nil +} + +// RecoverSystemProxy clears a system proxy left on after an unclean exit. +func (m *Manager) RecoverSystemProxy() { + if m == nil || m.sys == nil { + return + } + if !sysproxy.HasSentinel(m.cfgPath) { + return + } + if err := m.sys.ForceDisable(); err != nil { + fmt.Fprintf(m.stderr, "recover system proxy: %v\n", err) + } + sysproxy.ClearSentinel(m.cfgPath) } func (m *Manager) Connect(ctx context.Context, profileName string) error { @@ -90,6 +106,8 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error { _ = engine.Stop() return fmt.Errorf("enable system proxy: %w", err) } + } else { + sysproxy.WriteSentinel(m.cfgPath, httpProxy) } } @@ -107,10 +125,21 @@ func (m *Manager) Disconnect() error { m.mu.Unlock() var first error - if sys != nil && sys.Enabled() { - if err := sys.Disable(); err != nil && first == nil { - first = err + if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(m.cfgPath)) { + var err error + if sys.Enabled() { + err = sys.Disable() + } else { + err = sys.ForceDisable() } + if err != nil { + if err2 := sys.ForceDisable(); err2 != nil && first == nil { + first = err2 + } else if first == nil { + first = err + } + } + sysproxy.ClearSentinel(m.cfgPath) } if engine != nil { done := make(chan error, 1) @@ -507,26 +536,39 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) { // EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded). func (m *Manager) EnsureAllCores() (map[string]string, error) { + type item struct { + key string + path string + err error + } + jobs := []struct { + key string + fn func() (string, error) + }{ + {"naive", func() (string, error) { return naive.EnsureBinary(m.binDir) }}, + {"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(m.binDir) }}, + {"awg", func() (string, error) { return awg.EnsureBinary(m.binDir) }}, + {"xray", func() (string, error) { return xray.EnsureBinary(m.binDir) }}, + } + ch := make(chan item, len(jobs)) + for _, j := range jobs { + j := j + go func() { + path, err := j.fn() + ch <- item{key: j.key, path: path, err: err} + }() + } out := map[string]string{} - p1, err := naive.EnsureBinary(m.binDir) - if err != nil { - return out, fmt.Errorf("naive: %w", err) + var first error + for range jobs { + it := <-ch + if it.err != nil { + if first == nil { + first = fmt.Errorf("%s: %w", it.key, it.err) + } + continue + } + out[it.key] = it.path } - out["naive"] = p1 - p2, err := hysteria2.EnsureBinary(m.binDir) - if err != nil { - return out, fmt.Errorf("hysteria2: %w", err) - } - out["hysteria2"] = p2 - p3, err := awg.EnsureBinary(m.binDir) - if err != nil { - return out, fmt.Errorf("awg: %w", err) - } - out["awg"] = p3 - p4, err := xray.EnsureBinary(m.binDir) - if err != nil { - return out, fmt.Errorf("xray: %w", err) - } - out["xray"] = p4 - return out, nil + return out, first } diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 0279762..f19bc27 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -212,7 +212,8 @@ func probeUDP(ctx context.Context, host, port string) error { return nil } if ne, ok := err.(net.Error); ok && ne.Timeout() { - return nil + // UDP is connectionless: no reply does NOT mean the port is open. + return fmt.Errorf("udp timeout") } return err } diff --git a/internal/protocols/hysteria2/binary.go b/internal/protocols/hysteria2/binary.go index da32488..910c0db 100644 --- a/internal/protocols/hysteria2/binary.go +++ b/internal/protocols/hysteria2/binary.go @@ -181,6 +181,6 @@ func downloadFile(path, url string) error { return err } defer f.Close() - _, err = io.Copy(f, resp.Body) + _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20)) return err } diff --git a/internal/protocols/naive/binary.go b/internal/protocols/naive/binary.go index f28340b..fc77702 100644 --- a/internal/protocols/naive/binary.go +++ b/internal/protocols/naive/binary.go @@ -173,7 +173,7 @@ func downloadFile(path, url string) error { return err } defer f.Close() - _, err = io.Copy(f, resp.Body) + _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20)) return err } diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go index e535485..a6edf2e 100644 --- a/internal/subscription/fetch.go +++ b/internal/subscription/fetch.go @@ -27,7 +27,6 @@ type Item struct { var ( reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) - reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`) ) // Fetch downloads a subscription body and parses proxy share links. @@ -37,8 +36,8 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) { return nil, fmt.Errorf("пустой URL подписки") } u, err := url.Parse(rawURL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return nil, fmt.Errorf("нужен http(s) URL подписки") + if err != nil || u.Scheme != "https" { + return nil, fmt.Errorf("нужен https URL подписки") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) @@ -219,7 +218,6 @@ func extractClashHY2(text string) []string { } out = append(out, link) } - _ = reClashHY2 // kept for possible future tightening return out } diff --git a/internal/sysproxy/darwin.go b/internal/sysproxy/darwin.go index 9fa6109..0716d66 100644 --- a/internal/sysproxy/darwin.go +++ b/internal/sysproxy/darwin.go @@ -82,7 +82,16 @@ func (c *darwinController) Disable() error { if !c.enabled { return nil } - services := c.services + return c.disableServices(c.services) +} + +func (c *darwinController) ForceDisable() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.disableServices(nil) +} + +func (c *darwinController) disableServices(services []string) error { if len(services) == 0 { var err error services, err = listNetworkServices() diff --git a/internal/sysproxy/other.go b/internal/sysproxy/other.go index 1688305..89100cc 100644 --- a/internal/sysproxy/other.go +++ b/internal/sysproxy/other.go @@ -9,3 +9,4 @@ func newPlatform() Controller { return stubController{} } func (stubController) Enable(string) error { return ErrUnsupported } func (stubController) Disable() error { return nil } func (stubController) Enabled() bool { return false } +func (stubController) ForceDisable() error { return nil } diff --git a/internal/sysproxy/sentinel.go b/internal/sysproxy/sentinel.go new file mode 100644 index 0000000..323b8c6 --- /dev/null +++ b/internal/sysproxy/sentinel.go @@ -0,0 +1,39 @@ +package sysproxy + +import ( + "os" + "path/filepath" + "strings" +) + +// SentinelPath is written while Navis owns the OS system proxy. +// Survives crash/kill so the next launch can clear a stuck proxy. +func SentinelPath(cfgPath string) string { + dir := filepath.Dir(strings.TrimSpace(cfgPath)) + if dir == "" || dir == "." { + dir, _ = os.UserConfigDir() + if dir == "" { + dir = os.TempDir() + } + dir = filepath.Join(dir, "Navis") + } + return filepath.Join(dir, ".navis-proxy-on") +} + +// WriteSentinel marks that system proxy was enabled by Navis. +func WriteSentinel(cfgPath, httpHostPort string) { + path := SentinelPath(cfgPath) + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(strings.TrimSpace(httpHostPort)+"\n"), 0o600) +} + +// ClearSentinel removes the ownership marker after a clean Disable. +func ClearSentinel(cfgPath string) { + _ = os.Remove(SentinelPath(cfgPath)) +} + +// HasSentinel reports whether a previous session left the proxy marked on. +func HasSentinel(cfgPath string) bool { + _, err := os.Stat(SentinelPath(cfgPath)) + return err == nil +} diff --git a/internal/sysproxy/sysproxy.go b/internal/sysproxy/sysproxy.go index 11b70ee..4a7add8 100644 --- a/internal/sysproxy/sysproxy.go +++ b/internal/sysproxy/sysproxy.go @@ -7,6 +7,9 @@ type Controller interface { Enable(httpHostPort string) error Disable() error Enabled() bool + // ForceDisable turns off proxy settings even if this process did not Enable them + // (used to recover after crash/kill when a sentinel file remains). + ForceDisable() error } // New returns a platform-specific controller. diff --git a/internal/sysproxy/windows.go b/internal/sysproxy/windows.go index f6f5669..c43b153 100644 --- a/internal/sysproxy/windows.go +++ b/internal/sysproxy/windows.go @@ -95,6 +95,14 @@ func (c *windowsController) Disable() error { if !c.enabled { return nil } + return c.disableLocked(true) +} + +func (c *windowsController) ForceDisable() error { + return c.disableLocked(false) +} + +func (c *windowsController) disableLocked(restoreSaved bool) error { key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.QUERY_VALUE|registry.SET_VALUE) @@ -103,7 +111,7 @@ func (c *windowsController) Disable() error { } defer key.Close() - if c.hadProxy { + if restoreSaved && c.hadProxy { _ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable)) if c.oldServer != "" { _ = key.SetStringValue("ProxyServer", c.oldServer) @@ -114,12 +122,21 @@ func (c *windowsController) Disable() error { _ = key.SetStringValue("ProxyOverride", c.oldOverride) } } else { - _ = key.SetDWordValue("ProxyEnable", 0) + // Crash recovery: if proxy still points at local Navis ports, clear it. + server, _, _ := key.GetStringValue("ProxyServer") + lower := strings.ToLower(server) + if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") { + _ = key.SetDWordValue("ProxyEnable", 0) + _ = key.DeleteValue("ProxyServer") + } else if !restoreSaved { + _ = key.SetDWordValue("ProxyEnable", 0) + } } _ = notifyInternetSettingsChanged() c.enabled = false + c.hadProxy = false return nil } diff --git a/internal/update/update.go b/internal/update/update.go index 483e7e3..ea2e71b 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 = "2.7.2" +const CurrentVersion = "2.7.3" // 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 = 2 +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" @@ -289,16 +289,18 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, } } } - if wantSHA != "" { - sum, err := fileSHA256(tmp) - if err != nil { - _ = os.Remove(tmp) - return "", "", "", "", "", err - } - if !strings.EqualFold(sum, wantSHA) { - _ = os.Remove(tmp) - return "", "", "", "", "", fmt.Errorf("checksum mismatch") - } + if wantSHA == "" { + _ = os.Remove(tmp) + return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено") + } + sum, err := fileSHA256(tmp) + if err != nil { + _ = os.Remove(tmp) + return "", "", "", "", "", err + } + if !strings.EqualFold(sum, wantSHA) { + _ = os.Remove(tmp) + return "", "", "", "", "", fmt.Errorf("checksum mismatch") } return st.Latest, st.URL, wantSHA, exe, tmp, nil } diff --git a/internal/update/update_windows.go b/internal/update/update_windows.go index cf11548..dd9e986 100644 --- a/internal/update/update_windows.go +++ b/internal/update/update_windows.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "syscall" "time" @@ -73,6 +74,11 @@ func MaybeFinishUpdate(args []string) bool { self, _ = filepath.Abs(self) target, _ = filepath.Abs(target) + // Only allow replacing Navis.exe in the same directory as this pending updater. + if !safeUpdateTarget(self, target) { + return true + } + waitPIDExit(uint32(pid), 120*time.Second) time.Sleep(400 * time.Millisecond) @@ -93,6 +99,16 @@ func MaybeFinishUpdate(args []string) bool { return true } +func safeUpdateTarget(self, target string) bool { + selfDir := filepath.Clean(filepath.Dir(self)) + targetDir := filepath.Clean(filepath.Dir(target)) + if selfDir != targetDir { + return false + } + base := strings.ToLower(filepath.Base(target)) + return base == "navis.exe" +} + // CleanupStaleDownloads removes leftover update temps from failed/old runs. func CleanupStaleDownloads() { exe, err := os.Executable() diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh index b23aa9e..936bd96 100755 --- a/scripts/build-macos-arm64.sh +++ b/scripts/build-macos-arm64.sh @@ -59,7 +59,7 @@ build = "${BUILD}" full = "${FULL}" binp = Path('dist/navis-release/darwin-arm64/Navis') h = hashlib.sha256(binp.read_bytes()).hexdigest() -notes = f"Navis {ver}+{build}: иконка приложения (N), macOS AppIcon; обновление только по кнопке." +notes = f"Navis {ver}+{build}: восстановление system proxy после crash; auth macOS /api; обязательный SHA обновлений; fix UDP ping; HTTPS-only подписки; UI без зависания на connect." for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]: if not p.exists(): continue diff --git a/server/update.json b/server/update.json index 5dd969b..86ac28e 100644 --- a/server/update.json +++ b/server/update.json @@ -1,6 +1,6 @@ { - "version": "2.7.2", - "notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».", + "version": "2.7.3", + "notes": "2.7.3+1: восстановление system proxy после crash; auth для macOS /api; обязательный SHA обновлений; fix UDP ping; HTTPS-only подписки; UI без зависания на connect.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", @@ -16,7 +16,7 @@ }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb", + "sha256": "847fe952e6d1dfb83bb74661accbbe2c6df0f4261a4c0619c0e20e0a7faae3ca", "os": "darwin", "arch": "arm64", "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", @@ -39,4 +39,4 @@ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip" } } -} \ No newline at end of file +} diff --git a/versioninfo.json b/versioninfo.json index 79a5408..b9acf1a 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,17 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { - "Major": 2, - "Minor": 7, - "Patch": 2, - "Build": 2 - }, - "ProductVersion": { - "Major": 2, - "Minor": 7, - "Patch": 2, - "Build": 2 - }, + "FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 1 }, + "ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 1 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -21,12 +11,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "2.7.2.2", + "FileVersion": "2.7.3.1", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "2.7.2.2", + "ProductVersion": "2.7.3.1", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {