Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks.
This commit is contained in:
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
|
|||||||
*.apk
|
*.apk
|
||||||
!dist/navis-release/android/*.apk
|
!dist/navis-release/android/*.apk
|
||||||
|
|
||||||
|
# Local backups (pre-release archives)
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Local Go toolchain
|
||||||
|
.tools/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,12 @@ https://evilfox.win/
|
|||||||
|
|
||||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||||
|
|
||||||
|
В 3.8.0:
|
||||||
|
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
|
||||||
|
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
|
||||||
|
- SHA-256 проверка при установке cores (naive / hy2 / xray);
|
||||||
|
- snapshot конфига в UI (`Config().Clone`).
|
||||||
|
|
||||||
В 2.7.3:
|
В 2.7.3:
|
||||||
- восстановление системного прокси после аварийного завершения;
|
- восстановление системного прокси после аварийного завершения;
|
||||||
- auth-токен для локального macOS `/api`;
|
- auth-токен для локального macOS `/api`;
|
||||||
|
|||||||
+3
-3
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
|
|||||||
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
||||||
if errorlevel 1 exit /b 1
|
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.3 -build 2.7.3.3 -arch arm64
|
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.1 -arch arm64
|
||||||
if errorlevel 1 exit /b 1
|
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.3 -build 2.7.3.3 -arch amd64
|
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.1 -arch amd64
|
||||||
if errorlevel 1 exit /b 1
|
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.3 -build 2.7.3.3 -arch universal
|
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.1 -arch universal
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
+28
-402
@@ -4,60 +4,23 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/jchv/go-webview2"
|
"github.com/jchv/go-webview2"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
"vpnclient/internal/apphost"
|
||||||
"vpnclient/internal/appui"
|
"vpnclient/internal/appui"
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
"vpnclient/internal/netcheck"
|
|
||||||
"vpnclient/internal/protocols/awg"
|
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
|
||||||
"vpnclient/internal/protocols/naive"
|
|
||||||
"vpnclient/internal/protocols/xray"
|
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
type app struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
mgr *core.Manager
|
|
||||||
cfgPath string
|
|
||||||
logBuf *bytes.Buffer
|
|
||||||
updateStatus update.Status
|
|
||||||
pings []netcheck.Result
|
|
||||||
webview webview2.WebView
|
|
||||||
}
|
|
||||||
|
|
||||||
type uiState struct {
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
Profile string `json:"profile,omitempty"`
|
|
||||||
ActiveProfile string `json:"active_profile,omitempty"`
|
|
||||||
Protocol string `json:"protocol,omitempty"`
|
|
||||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
|
||||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
|
||||||
SystemProxy bool `json:"system_proxy"`
|
|
||||||
Proxy string `json:"proxy"`
|
|
||||||
CoreReady bool `json:"core_ready"`
|
|
||||||
CorePath string `json:"core_path,omitempty"`
|
|
||||||
ConfigPath string `json:"config_path"`
|
|
||||||
Profiles []config.ProfileInfo `json:"profiles"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Update update.Status `json:"update"`
|
|
||||||
Pings []netcheck.Result `json:"pings"`
|
|
||||||
Subscription string `json:"subscription_url"`
|
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if update.MaybeFinishUpdate(os.Args[1:]) {
|
if update.MaybeFinishUpdate(os.Args[1:]) {
|
||||||
return
|
return
|
||||||
@@ -87,27 +50,25 @@ func main() {
|
|||||||
fatalDialog("%v", err)
|
fatalDialog("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a := &app{
|
a := apphost.New(mgr, cfgPath, logBuf)
|
||||||
mgr: mgr,
|
a.OpenURL = shellOpen
|
||||||
cfgPath: cfgPath,
|
a.OnAfterUpdate = func() {
|
||||||
logBuf: logBuf,
|
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
|
||||||
updateStatus: update.Status{
|
go func() {
|
||||||
Current: update.DisplayVersion(),
|
time.Sleep(300 * time.Millisecond)
|
||||||
ManifestURL: update.DefaultManifestURL,
|
os.Exit(0)
|
||||||
},
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
|
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
|
||||||
_ = os.MkdirAll(dataPath, 0o755)
|
_ = os.MkdirAll(dataPath, 0o755)
|
||||||
|
|
||||||
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
|
||||||
|
|
||||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
Debug: false,
|
Debug: false,
|
||||||
AutoFocus: true,
|
AutoFocus: true,
|
||||||
DataPath: dataPath,
|
DataPath: dataPath,
|
||||||
WindowOptions: webview2.WindowOptions{
|
WindowOptions: webview2.WindowOptions{
|
||||||
Title: "Navis 2",
|
Title: "Navis",
|
||||||
Width: 500,
|
Width: 500,
|
||||||
Height: 900,
|
Height: 900,
|
||||||
Center: true,
|
Center: true,
|
||||||
@@ -117,33 +78,32 @@ func main() {
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
|
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
|
||||||
}
|
}
|
||||||
a.webview = w
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = a.mgr.Disconnect()
|
_ = mgr.Disconnect()
|
||||||
w.Destroy()
|
w.Destroy()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
applyWindowIcon(w.Window())
|
applyWindowIcon(w.Window())
|
||||||
w.SetSize(500, 900, webview2.HintNone)
|
w.SetSize(500, 900, webview2.HintNone)
|
||||||
|
|
||||||
mustBind(w, "getState", a.getState)
|
mustBind(w, "getState", a.GetState)
|
||||||
mustBind(w, "connect", a.connect)
|
mustBind(w, "connect", a.Connect)
|
||||||
mustBind(w, "disconnect", a.disconnect)
|
mustBind(w, "disconnect", a.Disconnect)
|
||||||
mustBind(w, "connectProfile", a.connectProfile)
|
mustBind(w, "connectProfile", a.ConnectProfile)
|
||||||
mustBind(w, "saveProfile", a.saveProfile)
|
mustBind(w, "saveProfile", a.SaveProfile)
|
||||||
mustBind(w, "createProfile", a.createProfile)
|
mustBind(w, "createProfile", a.CreateProfile)
|
||||||
mustBind(w, "selectProfile", a.selectProfile)
|
mustBind(w, "selectProfile", a.SelectProfile)
|
||||||
mustBind(w, "deleteProfile", a.deleteProfile)
|
mustBind(w, "deleteProfile", a.DeleteProfile)
|
||||||
mustBind(w, "installCore", a.installCore)
|
mustBind(w, "installCore", a.InstallCore)
|
||||||
mustBind(w, "openURL", openURL)
|
mustBind(w, "openURL", a.OpenShopURL)
|
||||||
mustBind(w, "pingServers", a.pingServers)
|
mustBind(w, "pingServers", a.PingServers)
|
||||||
mustBind(w, "pingBest", a.pingBest)
|
mustBind(w, "pingBest", a.PingBest)
|
||||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
mustBind(w, "checkUpdate", a.CheckUpdate)
|
||||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
mustBind(w, "applyUpdate", a.ApplyUpdate)
|
||||||
mustBind(w, "saveHy2", a.saveHy2)
|
mustBind(w, "saveHy2", a.SaveHy2)
|
||||||
mustBind(w, "importSubscription", a.importSubscription)
|
mustBind(w, "importSubscription", a.ImportSubscription)
|
||||||
|
|
||||||
go a.autoCheckUpdate()
|
go a.AutoCheckUpdate()
|
||||||
|
|
||||||
w.SetHtml(appui.IndexHTML)
|
w.SetHtml(appui.IndexHTML)
|
||||||
w.Run()
|
w.Run()
|
||||||
@@ -155,340 +115,6 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) getState() (uiState, error) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer 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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
out := uiState{
|
|
||||||
Connected: st.Connected,
|
|
||||||
Profile: st.Profile,
|
|
||||||
ActiveProfile: active,
|
|
||||||
Protocol: string(st.Protocol),
|
|
||||||
HTTPProxy: st.HTTPProxy,
|
|
||||||
SOCKSProxy: st.SOCKSProxy,
|
|
||||||
SystemProxy: cfg.SystemProxy,
|
|
||||||
Proxy: proxy,
|
|
||||||
CoreReady: coreReady,
|
|
||||||
CorePath: corePath,
|
|
||||||
ConfigPath: a.cfgPath,
|
|
||||||
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
|
||||||
Version: update.DisplayVersion(),
|
|
||||||
Update: a.updateStatus,
|
|
||||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
|
||||||
Subscription: cfg.SubscriptionURL,
|
|
||||||
Hy2: a.mgr.ActiveHy2Options(),
|
|
||||||
}
|
|
||||||
if out.Protocol == "" {
|
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
|
||||||
out.Protocol = string(p.Protocol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if st.Connected {
|
|
||||||
out.SystemProxy = st.SystemProxy
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("укажите название профиля")
|
|
||||||
}
|
|
||||||
a.mgr.SetSystemProxy(systemProxy)
|
|
||||||
return a.mgr.SaveActiveProfile(name, proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("укажите название профиля")
|
|
||||||
}
|
|
||||||
a.mgr.SetSystemProxy(systemProxy)
|
|
||||||
return a.mgr.SaveProfile(name, proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) selectProfile(name string) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return a.mgr.SetActiveProfile(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) deleteProfile(name string) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return a.mgr.DeleteProfile(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) connect() error {
|
|
||||||
return a.connectProfile("")
|
|
||||||
}
|
|
||||||
|
|
||||||
// connectProfile switches to the named profile (if set) and connects.
|
|
||||||
// If already connected to another profile, disconnects first.
|
|
||||||
func (a *app) connectProfile(name string) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return a.mgr.Connect(ctx, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) disconnect() error {
|
|
||||||
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
|
|
||||||
return a.mgr.Disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) installCore() (string, error) {
|
|
||||||
paths, err := a.mgr.EnsureAllCores()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(paths))
|
|
||||||
for k, v := range paths {
|
|
||||||
parts = append(parts, k+": "+v)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " | "), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) saveHy2(opts core.Hy2Options) error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return a.mgr.SaveHy2Options(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) importSubscription(rawURL string) (int, error) {
|
|
||||||
return a.mgr.ImportSubscription(rawURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
type pingBestResult struct {
|
|
||||||
Pings []netcheck.Result `json:"pings"`
|
|
||||||
BestName string `json:"best_name,omitempty"`
|
|
||||||
BestMs int64 `json:"best_ms,omitempty"`
|
|
||||||
Selected bool `json:"selected"`
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) pingServers() ([]netcheck.Result, error) {
|
|
||||||
res, err := a.runPings()
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
|
|
||||||
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
|
|
||||||
pings, err := a.runPings()
|
|
||||||
out := pingBestResult{Pings: pings}
|
|
||||||
if err != nil {
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
best, ok := netcheck.BestOK(pings)
|
|
||||||
if !ok {
|
|
||||||
return out, fmt.Errorf("нет доступных серверов")
|
|
||||||
}
|
|
||||||
out.BestName = best.Name
|
|
||||||
out.BestMs = best.Ms
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := a.mgr.Connect(ctx, ""); err != nil {
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
out.Connected = true
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) runPings() ([]netcheck.Result, error) {
|
|
||||||
a.mu.Lock()
|
|
||||||
list := a.mgr.Profiles()
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
targets := make([]netcheck.Target, 0, len(list))
|
|
||||||
for _, p := range list {
|
|
||||||
targets = append(targets, netcheck.Target{
|
|
||||||
Name: p.Name,
|
|
||||||
Protocol: config.Protocol(p.Protocol),
|
|
||||||
Proxy: p.Proxy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
out := netcheck.PingAll(ctx, targets)
|
|
||||||
a.mu.Lock()
|
|
||||||
a.pings = out
|
|
||||||
a.mu.Unlock()
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) checkUpdate() (update.Status, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
st, err := update.Check(ctx, update.DefaultManifestURL)
|
|
||||||
a.mu.Lock()
|
|
||||||
if err != nil {
|
|
||||||
st.Current = update.DisplayVersion()
|
|
||||||
st.ManifestURL = update.DefaultManifestURL
|
|
||||||
st.Error = err.Error()
|
|
||||||
}
|
|
||||||
a.updateStatus = st
|
|
||||||
a.mu.Unlock()
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) applyUpdate() (string, error) {
|
|
||||||
// Explicit user action only — never called from autoCheckUpdate.
|
|
||||||
st, err := a.checkUpdate()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !st.Available {
|
|
||||||
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
latest, err := update.Apply(ctx, update.DefaultManifestURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = a.mgr.Disconnect()
|
|
||||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
|
|
||||||
go func() {
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
os.Exit(0)
|
|
||||||
}()
|
|
||||||
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) autoCheckUpdate() {
|
|
||||||
// Only refresh update status for the UI banner — never auto-install.
|
|
||||||
// (Previously auto-apply caused an infinite update loop when the feed version
|
|
||||||
// stayed ahead of the shipped CurrentVersion in the downloaded binary.)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
_, _ = a.checkUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func openURL(raw string) error {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return fmt.Errorf("пустая ссылка")
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(raw)
|
|
||||||
switch {
|
|
||||||
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
|
||||||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
|
||||||
raw = "https://evilfox.win/"
|
|
||||||
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
|
||||||
// allow shop deep-links on evilfox.win only
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
|
||||||
}
|
|
||||||
return shellOpen(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shellOpen(raw string) error {
|
func shellOpen(raw string) error {
|
||||||
verb, err := windows.UTF16PtrFromString("open")
|
verb, err := windows.UTF16PtrFromString("open")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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.3+3
|
3.8.0+1
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.7.3.3
|
3.8.0.1
|
||||||
|
|||||||
Vendored
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "2.7.3",
|
"version": "3.8.0",
|
||||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"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.3",
|
"version": "3.8.0",
|
||||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||||
|
|||||||
@@ -848,7 +848,7 @@
|
|||||||
<div class="brand-wrap">
|
<div class="brand-wrap">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<h1 class="brand">Navis</h1>
|
<h1 class="brand">Navis</h1>
|
||||||
<span class="badge-ver" id="badgeVer">2.7.3</span>
|
<span class="badge-ver" id="badgeVer">3.8.0</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ func lastIndex(s, substr string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy of the config (JSON round-trip).
|
||||||
|
func (c *Config) Clone() *Config {
|
||||||
|
if c == nil {
|
||||||
|
return &Config{}
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
out := *c
|
||||||
|
out.Profiles = append([]Profile(nil), c.Profiles...)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
var out Config
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
cp := *c
|
||||||
|
cp.Profiles = append([]Profile(nil), c.Profiles...)
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads and validates a config file.
|
// Load reads and validates a config file.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Manager struct {
|
|||||||
profile *config.Profile
|
profile *config.Profile
|
||||||
stderr io.Writer
|
stderr io.Writer
|
||||||
binDir string
|
binDir string
|
||||||
|
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
|
||||||
|
lastStop sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||||
@@ -68,13 +70,25 @@ func (m *Manager) RecoverSystemProxy() {
|
|||||||
sysproxy.ClearSentinel(m.cfgPath)
|
sysproxy.ClearSentinel(m.cfgPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) waitEngineStopped() {
|
||||||
|
m.lastStop.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||||
|
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
|
||||||
|
m.waitEngineStopped()
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if m.engine != nil && m.engine.Running() {
|
if m.engine != nil && m.engine.Running() {
|
||||||
return fmt.Errorf("already connected; disconnect first")
|
return fmt.Errorf("already connected; disconnect first")
|
||||||
}
|
}
|
||||||
|
if m.engine != nil {
|
||||||
|
// Defunct reference (stop finished or engine died) — drop before start.
|
||||||
|
m.engine = nil
|
||||||
|
m.profile = nil
|
||||||
|
}
|
||||||
|
|
||||||
if profileName != "" {
|
if profileName != "" {
|
||||||
m.cfg.Active = profileName
|
m.cfg.Active = profileName
|
||||||
@@ -122,6 +136,9 @@ func (m *Manager) Disconnect() error {
|
|||||||
engine := m.engine
|
engine := m.engine
|
||||||
m.engine = nil
|
m.engine = nil
|
||||||
m.profile = nil
|
m.profile = nil
|
||||||
|
if engine != nil {
|
||||||
|
m.lastStop.Add(1)
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
var first error
|
var first error
|
||||||
@@ -143,14 +160,17 @@ func (m *Manager) Disconnect() error {
|
|||||||
}
|
}
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- engine.Stop() }()
|
go func() {
|
||||||
|
defer m.lastStop.Done()
|
||||||
|
done <- engine.Stop()
|
||||||
|
}()
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
if err != nil && first == nil {
|
if err != nil && first == nil {
|
||||||
first = err
|
first = err
|
||||||
}
|
}
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
// Engine Stop can block on userspace relays; don't freeze the UI.
|
// Stop continues in background; Connect will wait on lastStop.
|
||||||
if first == nil {
|
if first == nil {
|
||||||
first = fmt.Errorf("отключение заняло слишком много времени")
|
first = fmt.Errorf("отключение заняло слишком много времени")
|
||||||
}
|
}
|
||||||
@@ -236,10 +256,14 @@ func (m *Manager) BinDir() string { return m.binDir }
|
|||||||
|
|
||||||
func (m *Manager) ConfigPath() string { return m.cfgPath }
|
func (m *Manager) ConfigPath() string { return m.cfgPath }
|
||||||
|
|
||||||
|
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
|
||||||
func (m *Manager) Config() *config.Config {
|
func (m *Manager) Config() *config.Config {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
return m.cfg
|
if m.cfg == nil {
|
||||||
|
return &config.Config{}
|
||||||
|
}
|
||||||
|
return m.cfg.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetSystemProxy(enabled bool) {
|
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vpnclient/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeEngine struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
stopDelay time.Duration
|
||||||
|
stops atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
|
||||||
|
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.running = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (e *fakeEngine) Stop() error {
|
||||||
|
e.stops.Add(1)
|
||||||
|
if e.stopDelay > 0 {
|
||||||
|
time.Sleep(e.stopDelay)
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.running = false
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (e *fakeEngine) Running() bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.running
|
||||||
|
}
|
||||||
|
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
|
||||||
|
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
|
||||||
|
|
||||||
|
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Active: "t",
|
||||||
|
SystemProxy: false,
|
||||||
|
BinDir: t.TempDir(),
|
||||||
|
Profiles: []config.Profile{{
|
||||||
|
Name: "t",
|
||||||
|
Protocol: config.ProtocolNaive,
|
||||||
|
Proxy: "https://u:p@example.com",
|
||||||
|
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.engine = eng
|
||||||
|
m.profile = &cfg.Profiles[0]
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_ = m.Disconnect()
|
||||||
|
// Connect must wait for Stop to finish even if Disconnect returned early...
|
||||||
|
// our Disconnect waits up to 3s, so with 200ms it should complete.
|
||||||
|
if time.Since(start) < 150*time.Millisecond {
|
||||||
|
t.Fatalf("disconnect returned too fast")
|
||||||
|
}
|
||||||
|
if eng.Running() {
|
||||||
|
t.Fatal("engine still running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate slow stop that outlives Disconnect timeout.
|
||||||
|
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.engine = eng2
|
||||||
|
m.profile = &cfg.Profiles[0]
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Force short path: call the wait mechanism like Connect does.
|
||||||
|
m.mu.Lock()
|
||||||
|
engine := m.engine
|
||||||
|
m.engine = nil
|
||||||
|
m.profile = nil
|
||||||
|
m.lastStop.Add(1)
|
||||||
|
m.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
defer m.lastStop.Done()
|
||||||
|
_ = engine.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitStart := time.Now()
|
||||||
|
m.waitEngineStopped()
|
||||||
|
if time.Since(waitStart) < 400*time.Millisecond {
|
||||||
|
t.Fatalf("waitEngineStopped returned before stop finished")
|
||||||
|
}
|
||||||
|
if eng2.Running() {
|
||||||
|
t.Fatal("engine2 still running after wait")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigReturnsClone(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Active: "a",
|
||||||
|
Profiles: []config.Profile{{
|
||||||
|
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snap := m.Config()
|
||||||
|
snap.Active = "mutated"
|
||||||
|
snap.Profiles[0].Proxy = "leaked"
|
||||||
|
if m.cfg.Active != "a" {
|
||||||
|
t.Fatal("Config() leaked mutable Active")
|
||||||
|
}
|
||||||
|
if m.cfg.Profiles[0].Proxy != "https://x" {
|
||||||
|
t.Fatal("Config() leaked mutable Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package coredl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
|
||||||
|
type ReleaseAsset struct {
|
||||||
|
Name string
|
||||||
|
BrowserDownloadURL string
|
||||||
|
SHA256 string // lowercase hex, empty if unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ghRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLatestAssets loads assets for a repo's latest GitHub release.
|
||||||
|
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("github api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||||
|
}
|
||||||
|
var rel ghRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
out := make([]ReleaseAsset, 0, len(rel.Assets))
|
||||||
|
for _, a := range rel.Assets {
|
||||||
|
out = append(out, ReleaseAsset{
|
||||||
|
Name: a.Name,
|
||||||
|
BrowserDownloadURL: a.BrowserDownloadURL,
|
||||||
|
SHA256: parseDigestSHA256(a.Digest),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rel.TagName, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDigestSHA256(digest string) string {
|
||||||
|
digest = strings.TrimSpace(strings.ToLower(digest))
|
||||||
|
if digest == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(digest, "sha256:") {
|
||||||
|
return strings.TrimPrefix(digest, "sha256:")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadFile writes url to path (size-capped).
|
||||||
|
func DownloadFile(path, url string, maxBytes int64) error {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = 120 << 20
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 15 * time.Minute}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSHA256 returns lowercase hex digest of path.
|
||||||
|
func FileSHA256(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
|
||||||
|
func VerifySHA256(path, want string) error {
|
||||||
|
want = strings.TrimSpace(strings.ToLower(want))
|
||||||
|
if want == "" {
|
||||||
|
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
|
||||||
|
}
|
||||||
|
got, err := FileSHA256(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireSHA returns sha or an error if the asset has none.
|
||||||
|
func RequireSHA(a ReleaseAsset) (string, error) {
|
||||||
|
if a.SHA256 != "" {
|
||||||
|
return a.SHA256, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
|
||||||
|
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
|
||||||
|
if a.SHA256 != "" {
|
||||||
|
return a.SHA256, nil
|
||||||
|
}
|
||||||
|
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
|
||||||
|
for _, name := range companions {
|
||||||
|
for _, o := range all {
|
||||||
|
if !strings.EqualFold(o.Name, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if sum != "" {
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RequireSHA(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchChecksumFile(url, assetName string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("checksum download: %s", resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
text := string(body)
|
||||||
|
lowerName := strings.ToLower(assetName)
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var shaLine string
|
||||||
|
for _, line := range lines {
|
||||||
|
l := strings.TrimSpace(strings.ToLower(line))
|
||||||
|
if strings.HasPrefix(l, "sha256:") {
|
||||||
|
shaLine = strings.TrimSpace(line[len("sha256:"):])
|
||||||
|
// keep scanning — prefer line that also mentions the asset
|
||||||
|
if strings.Contains(l, lowerName) {
|
||||||
|
return strings.ToLower(strings.Fields(shaLine)[0]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
sum := strings.ToLower(fields[0])
|
||||||
|
file := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||||
|
if len(sum) == 64 && strings.EqualFold(file, assetName) {
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shaLine != "" {
|
||||||
|
parts := strings.Fields(shaLine)
|
||||||
|
if len(parts) > 0 && len(parts[0]) == 64 {
|
||||||
|
return strings.ToLower(parts[0]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadAndVerify downloads url to path and checks sha256.
|
||||||
|
func DownloadAndVerify(path, url, wantSHA256 string) error {
|
||||||
|
if err := DownloadFile(path, url, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := VerifySHA256(path, wantSHA256); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package coredl
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseDigestAndVerify(t *testing.T) {
|
||||||
|
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
|
||||||
|
t.Fatalf("parse: %q", got)
|
||||||
|
}
|
||||||
|
if parseDigestSHA256("md5:x") != "" {
|
||||||
|
t.Fatal("expected empty for non-sha256")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
package hysteria2
|
package hysteria2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"vpnclient/internal/coredl"
|
||||||
)
|
)
|
||||||
|
|
||||||
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
|
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
|
||||||
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
|
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
|
||||||
name, url, err := findReleaseAsset(want)
|
asset, all, err := findReleaseAsset(want)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum, err := coredl.ResolveSHA(asset, all)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
|
|||||||
destName = "hysteria.exe"
|
destName = "hysteria.exe"
|
||||||
}
|
}
|
||||||
dest := filepath.Join(binDir, destName)
|
dest := filepath.Join(binDir, destName)
|
||||||
tmp := filepath.Join(binDir, name+".download")
|
tmp := filepath.Join(binDir, asset.Name+".download")
|
||||||
if err := downloadFile(tmp, url); err != nil {
|
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_ = os.Remove(dest)
|
_ = os.Remove(dest)
|
||||||
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ghRelease struct {
|
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||||
TagName string `json:"tag_name"`
|
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||||
Assets []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
|
||||||
} `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return coredl.ReleaseAsset{}, nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
var fallback coredl.ReleaseAsset
|
||||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
for _, a := range assets {
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("github api: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
var rel ghRelease
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
// Prefer exact non-avx name; fall back to avx variant on Windows.
|
|
||||||
var fallback string
|
|
||||||
var fallbackURL string
|
|
||||||
for _, a := range rel.Assets {
|
|
||||||
if a.Name == exactName {
|
if a.Name == exactName {
|
||||||
return a.Name, a.BrowserDownloadURL, nil
|
return a, assets, nil
|
||||||
}
|
}
|
||||||
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
|
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
|
||||||
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
|
fallback = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fallback != "" {
|
if fallback.Name != "" {
|
||||||
return fallback, fallbackURL, nil
|
return fallback, assets, nil
|
||||||
}
|
}
|
||||||
// Prefix match for versioned names
|
for _, a := range assets {
|
||||||
for _, a := range rel.Assets {
|
|
||||||
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
|
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
|
||||||
return a.Name, a.BrowserDownloadURL, nil
|
return a, assets, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(path, url string) error {
|
|
||||||
client := &http.Client{Timeout: 10 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
|
||||||
}
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ package naive
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"vpnclient/internal/coredl"
|
||||||
)
|
)
|
||||||
|
|
||||||
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
|
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
|
||||||
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
|
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
|
||||||
assetName, url, err := findReleaseAsset(pattern)
|
asset, all, err := findReleaseAsset(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
archivePath := filepath.Join(binDir, assetName)
|
sum, err := coredl.ResolveSHA(asset, all)
|
||||||
if err := downloadFile(archivePath, url); err != nil {
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
archivePath := filepath.Join(binDir, asset.Name+".download")
|
||||||
|
if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer os.Remove(archivePath)
|
defer os.Remove(archivePath)
|
||||||
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
|
|||||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||||
return "naiveproxy-*-linux-x64.zip", nil
|
return "naiveproxy-*-linux-x64.zip", nil
|
||||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||||
// Newer releases use mac-x64-x64; older used mac-x64.
|
|
||||||
return "naiveproxy-*-mac-x64", nil
|
return "naiveproxy-*-mac-x64", nil
|
||||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||||
// Newer releases use mac-arm64-arm64; older used mac-arm64.
|
|
||||||
return "naiveproxy-*-mac-arm64", nil
|
return "naiveproxy-*-mac-arm64", nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
|
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ghRelease struct {
|
func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||||
TagName string `json:"tag_name"`
|
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||||
Assets []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
|
||||||
} `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return coredl.ReleaseAsset{}, nil, err
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
req.Header.Set("User-Agent", "vpnclient-go")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("github api: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
var rel ghRelease
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
}
|
||||||
prefix := strings.Split(pattern, "*")[0]
|
prefix := strings.Split(pattern, "*")[0]
|
||||||
needle := ""
|
needle := ""
|
||||||
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
|
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
|
||||||
needle = parts[1]
|
needle = parts[1]
|
||||||
}
|
}
|
||||||
var bestName, bestURL string
|
var best coredl.ReleaseAsset
|
||||||
for _, a := range rel.Assets {
|
for _, a := range assets {
|
||||||
name := a.Name
|
name := a.Name
|
||||||
if strings.Contains(name, "plugin") {
|
if strings.Contains(name, "plugin") {
|
||||||
continue
|
continue
|
||||||
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
|||||||
if needle != "" && !strings.Contains(name, needle) {
|
if needle != "" && !strings.Contains(name, needle) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Prefer desktop archives over openwrt/apk.
|
|
||||||
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
|
if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.xz") || strings.HasSuffix(name, ".txz") {
|
||||||
return name, a.BrowserDownloadURL, nil
|
return a, assets, nil
|
||||||
}
|
}
|
||||||
if bestName == "" {
|
if best.Name == "" {
|
||||||
bestName, bestURL = name, a.BrowserDownloadURL
|
best = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bestName != "" {
|
if best.Name != "" {
|
||||||
return bestName, bestURL, nil
|
return best, assets, nil
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
|
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(path, url string) error {
|
|
||||||
client := &http.Client{Timeout: 10 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
|
||||||
}
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractNaiveArchive(archivePath, destDir string) error {
|
func extractNaiveArchive(archivePath, destDir string) error {
|
||||||
lower := strings.ToLower(archivePath)
|
lower := strings.ToLower(archivePath)
|
||||||
|
lower = strings.TrimSuffix(lower, ".download")
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(lower, ".zip"):
|
case strings.HasSuffix(lower, ".zip"):
|
||||||
return unzipNaive(archivePath, destDir)
|
return unzipNaive(archivePath, destDir)
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ package xray
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"vpnclient/internal/coredl"
|
||||||
)
|
)
|
||||||
|
|
||||||
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||||
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
|
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
|
||||||
name, url, err := findReleaseAsset(want)
|
asset, all, err := findReleaseAsset(want)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
zipPath := filepath.Join(binDir, name+".download")
|
sum, err := coredl.ResolveSHA(asset, all)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
zipPath := filepath.Join(binDir, asset.Name+".download")
|
||||||
_ = os.Remove(zipPath)
|
_ = os.Remove(zipPath)
|
||||||
if err := downloadFile(zipPath, url); err != nil {
|
if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer os.Remove(zipPath)
|
defer os.Remove(zipPath)
|
||||||
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
dest := filepath.Join(binDir, destName)
|
dest := filepath.Join(binDir, destName)
|
||||||
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
|
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
|
||||||
// Some zips nest the binary; try case-insensitive match.
|
|
||||||
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
|
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
|
||||||
return "", fmt.Errorf("%v; %w", err, err2)
|
return "", fmt.Errorf("%v; %w", err, err2)
|
||||||
}
|
}
|
||||||
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ghRelease struct {
|
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
|
||||||
TagName string `json:"tag_name"`
|
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
|
||||||
Assets []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
|
||||||
} `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return coredl.ReleaseAsset{}, nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
for _, a := range assets {
|
||||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
|
||||||
resp, err := client.Do(req)
|
return a, assets, nil
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("github api: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
var rel ghRelease
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
for _, a := range rel.Assets {
|
|
||||||
if a.Name == exactName {
|
|
||||||
return a.Name, a.BrowserDownloadURL, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, a := range rel.Assets {
|
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
|
||||||
if strings.EqualFold(a.Name, exactName) {
|
|
||||||
return a.Name, a.BrowserDownloadURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(path, url string) error {
|
|
||||||
client := &http.Client{Timeout: 15 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
|
||||||
}
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractNamedFromZip(zipPath, wantName, dest string) error {
|
func extractNamedFromZip(zipPath, wantName, dest string) error {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package sysproxy
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
"golang.org/x/sys/windows/registry"
|
"golang.org/x/sys/windows/registry"
|
||||||
@@ -16,6 +17,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type windowsController struct {
|
type windowsController struct {
|
||||||
|
mu sync.Mutex
|
||||||
enabled bool
|
enabled bool
|
||||||
hadProxy bool
|
hadProxy bool
|
||||||
oldEnable uint64
|
oldEnable uint64
|
||||||
@@ -27,9 +29,15 @@ func newPlatform() Controller {
|
|||||||
return &windowsController{}
|
return &windowsController{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *windowsController) Enabled() bool { return c.enabled }
|
func (c *windowsController) Enabled() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.enabled
|
||||||
|
}
|
||||||
|
|
||||||
func (c *windowsController) Enable(httpHostPort string) error {
|
func (c *windowsController) Enable(httpHostPort string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if httpHostPort == "" {
|
if httpHostPort == "" {
|
||||||
return fmt.Errorf("sysproxy: empty http proxy address")
|
return fmt.Errorf("sysproxy: empty http proxy address")
|
||||||
}
|
}
|
||||||
@@ -92,6 +100,8 @@ func (c *windowsController) Enable(httpHostPort string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *windowsController) Disable() error {
|
func (c *windowsController) Disable() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if !c.enabled {
|
if !c.enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -99,6 +109,8 @@ func (c *windowsController) Disable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *windowsController) ForceDisable() error {
|
func (c *windowsController) ForceDisable() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
return c.disableLocked(false)
|
return c.disableLocked(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import (
|
|||||||
|
|
||||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||||
// Keep major.minor.patch only — no build suffix here.
|
// Keep major.minor.patch only — no build suffix here.
|
||||||
const CurrentVersion = "2.7.3"
|
const CurrentVersion = "3.8.0"
|
||||||
|
|
||||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// 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).
|
// 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"
|
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ build = "${BUILD}"
|
|||||||
full = "${FULL}"
|
full = "${FULL}"
|
||||||
binp = Path('dist/navis-release/darwin-arm64/Navis')
|
binp = Path('dist/navis-release/darwin-arm64/Navis')
|
||||||
h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
h = hashlib.sha256(binp.read_bytes()).hexdigest()
|
||||||
notes = f"Navis {ver}+{build}: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке)."
|
notes = f"Navis {ver}+{build}: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot."
|
||||||
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
|
for p in [Path('dist/update.json'), Path('dist/navis-release/update.json')]:
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
continue
|
continue
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "2.7.3",
|
"version": "3.8.0",
|
||||||
"notes": "Navis 2.7.3+3: тёмная тема; fix импорта Amnezia vpn:// (sanitize, создание профиля при подключении, ошибка в модалке).",
|
"notes": "Navis 3.8.0+1: единый apphost (Win/macOS); lifecycle Connect/Disconnect; SHA cores; Config snapshot.",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "3cba96ea2124863a3f401fdcd05dc7b8bc24588241be239c793d0f34177023eb",
|
"sha256": "a9e55bbe230896976f463cce4872569c2aaeb897196301d46aaba07f90cfc2f2",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64",
|
"arch": "arm64",
|
||||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
|
"FileVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
|
||||||
"ProductVersion": { "Major": 2, "Minor": 7, "Patch": 3, "Build": 3 },
|
"ProductVersion": { "Major": 3, "Minor": 8, "Patch": 0, "Build": 1 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
},
|
},
|
||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "2.7.3.3",
|
"FileVersion": "3.8.0.1",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "2.7.3.3",
|
"ProductVersion": "3.8.0.1",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user