Release 2.7.3.1: harden proxy recovery, updates and local API.
Restore orphaned system proxy after crash, require update SHA-256, add macOS /api auth token, fix UDP ping false positives, HTTPS-only subscriptions, and keep the UI responsive during connect. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
+3
-3
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2.7.2+2
|
||||
2.7.3+1
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.7.2.2
|
||||
2.7.3.1
|
||||
|
||||
Vendored
+3
-3
@@ -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",
|
||||
|
||||
Vendored
+3
-3
@@ -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",
|
||||
|
||||
+61
-11
@@ -3,6 +3,8 @@ package apphost
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -34,6 +36,7 @@ type App struct {
|
||||
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, "</head>",
|
||||
"<script>window.__NAVIS_TOKEN__="+jsonString(a.APIToken)+";</script></head>", 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
|
||||
}
|
||||
|
||||
@@ -740,7 +740,7 @@
|
||||
<div class="brand-wrap">
|
||||
<div class="brand-row">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<span class="badge-ver" id="badgeVer">2.7.2</span>
|
||||
<span class="badge-ver" id="badgeVer">2.7.3</span>
|
||||
</div>
|
||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||
</div>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
+64
-22
@@ -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,11 +125,22 @@ func (m *Manager) Disconnect() error {
|
||||
m.mu.Unlock()
|
||||
|
||||
var first error
|
||||
if sys != nil && sys.Enabled() {
|
||||
if err := sys.Disable(); err != nil && first == nil {
|
||||
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)
|
||||
go func() { done <- engine.Stop() }()
|
||||
@@ -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)
|
||||
}
|
||||
out["naive"] = p1
|
||||
p2, err := hysteria2.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("hysteria2: %w", err)
|
||||
continue
|
||||
}
|
||||
out["hysteria2"] = p2
|
||||
p3, err := awg.EnsureBinary(m.binDir)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("awg: %w", err)
|
||||
out[it.key] = it.path
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +289,10 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
}
|
||||
}
|
||||
}
|
||||
if wantSHA != "" {
|
||||
if wantSHA == "" {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
}
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
@@ -299,6 +302,5 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
}
|
||||
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+4
-14
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user