Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks.

This commit is contained in:
M4
2026-07-29 21:43:41 +03:00
parent 64c097d1e7
commit 54b5b87990
26 changed files with 534 additions and 619 deletions
+1 -1
View File
@@ -848,7 +848,7 @@
<div class="brand-wrap">
<div class="brand-row">
<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>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
+20
View File
@@ -144,6 +144,26 @@ func lastIndex(s, substr string) int {
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.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
+27 -3
View File
@@ -33,6 +33,8 @@ type Manager struct {
profile *config.Profile
stderr io.Writer
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) {
@@ -68,13 +70,25 @@ func (m *Manager) RecoverSystemProxy() {
sysproxy.ClearSentinel(m.cfgPath)
}
func (m *Manager) waitEngineStopped() {
m.lastStop.Wait()
}
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()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
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 != "" {
m.cfg.Active = profileName
@@ -122,6 +136,9 @@ func (m *Manager) Disconnect() error {
engine := m.engine
m.engine = nil
m.profile = nil
if engine != nil {
m.lastStop.Add(1)
}
m.mu.Unlock()
var first error
@@ -143,14 +160,17 @@ func (m *Manager) Disconnect() error {
}
if engine != nil {
done := make(chan error, 1)
go func() { done <- engine.Stop() }()
go func() {
defer m.lastStop.Done()
done <- engine.Stop()
}()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
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 {
first = fmt.Errorf("отключение заняло слишком много времени")
}
@@ -236,10 +256,14 @@ func (m *Manager) BinDir() string { return m.binDir }
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 {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
if m.cfg == nil {
return &config.Config{}
}
return m.cfg.Clone()
}
func (m *Manager) SetSystemProxy(enabled bool) {
+127
View File
@@ -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")
}
}
+214
View File
@@ -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
}
+12
View File
@@ -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")
}
}
+21 -65
View File
@@ -1,16 +1,14 @@
package hysteria2
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
@@ -72,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
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 {
return "", err
}
@@ -81,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
destName = "hysteria.exe"
}
dest := filepath.Join(binDir, destName)
tmp := filepath.Join(binDir, name+".download")
if err := downloadFile(tmp, url); err != nil {
tmp := filepath.Join(binDir, asset.Name+".download")
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
_ = os.Remove(dest)
@@ -114,73 +116,27 @@ func releaseAssetName() (string, error) {
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
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)
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
return coredl.ReleaseAsset{}, 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 "", "", 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 {
var fallback coredl.ReleaseAsset
for _, a := range assets {
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" {
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
fallback = a
}
}
if fallback != "" {
return fallback, fallbackURL, nil
if fallback.Name != "" {
return fallback, assets, nil
}
// Prefix match for versioned names
for _, a := range rel.Assets {
for _, a := range assets {
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)
}
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
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
}
+21 -63
View File
@@ -2,16 +2,15 @@ package naive
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
@@ -51,12 +50,16 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
assetName, url, err := findReleaseAsset(pattern)
asset, all, err := findReleaseAsset(pattern)
if err != nil {
return "", err
}
archivePath := filepath.Join(binDir, assetName)
if err := downloadFile(archivePath, url); err != nil {
sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
archivePath := filepath.Join(binDir, asset.Name+".download")
if err := coredl.DownloadAndVerify(archivePath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(archivePath)
@@ -88,52 +91,26 @@ func assetNameForPlatform() (string, error) {
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
return "naiveproxy-*-linux-x64.zip", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
// Newer releases use mac-x64-x64; older used mac-x64.
return "naiveproxy-*-mac-x64", nil
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
// Newer releases use mac-arm64-arm64; older used mac-arm64.
return "naiveproxy-*-mac-arm64", nil
default:
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
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)
func findReleaseAsset(pattern string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", 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
return coredl.ReleaseAsset{}, nil, err
}
prefix := strings.Split(pattern, "*")[0]
needle := ""
if parts := strings.SplitN(pattern, "*", 2); len(parts) == 2 {
needle = parts[1]
}
var bestName, bestURL string
for _, a := range rel.Assets {
var best coredl.ReleaseAsset
for _, a := range assets {
name := a.Name
if strings.Contains(name, "plugin") {
continue
@@ -144,41 +121,22 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
if needle != "" && !strings.Contains(name, needle) {
continue
}
// Prefer desktop archives over openwrt/apk.
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 == "" {
bestName, bestURL = name, a.BrowserDownloadURL
if best.Name == "" {
best = a
}
}
if bestName != "" {
return bestName, bestURL, nil
if best.Name != "" {
return best, assets, nil
}
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
}
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
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset matching %s in naive release", pattern)
}
func extractNaiveArchive(archivePath, destDir string) error {
lower := strings.ToLower(archivePath)
lower = strings.TrimSuffix(lower, ".download")
switch {
case strings.HasSuffix(lower, ".zip"):
return unzipNaive(archivePath, destDir)
+16 -62
View File
@@ -2,16 +2,15 @@ package xray
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
@@ -56,13 +55,17 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want)
name, url, err := findReleaseAsset(want)
asset, all, err := findReleaseAsset(want)
if err != nil {
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)
if err := downloadFile(zipPath, url); err != nil {
if err := coredl.DownloadAndVerify(zipPath, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
defer os.Remove(zipPath)
@@ -73,7 +76,6 @@ func EnsureBinary(binDir string) (string, error) {
}
dest := filepath.Join(binDir, destName)
if err := extractNamedFromZip(zipPath, destName, dest); err != nil {
// Some zips nest the binary; try case-insensitive match.
if err2 := extractXrayFromZip(zipPath, dest); err2 != nil {
return "", fmt.Errorf("%v; %w", err, err2)
}
@@ -104,65 +106,17 @@ func releaseZipName() (string, error) {
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
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)
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
return coredl.ReleaseAsset{}, 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 "", "", 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 assets {
if a.Name == exactName || strings.EqualFold(a.Name, exactName) {
return a, assets, nil
}
}
for _, a := range rel.Assets {
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
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in xray release", exactName)
}
func extractNamedFromZip(zipPath, wantName, dest string) error {
+13 -1
View File
@@ -5,6 +5,7 @@ package sysproxy
import (
"fmt"
"strings"
"sync"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -16,6 +17,7 @@ const (
)
type windowsController struct {
mu sync.Mutex
enabled bool
hadProxy bool
oldEnable uint64
@@ -27,9 +29,15 @@ func newPlatform() Controller {
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 {
c.mu.Lock()
defer c.mu.Unlock()
if httpHostPort == "" {
return fmt.Errorf("sysproxy: empty http proxy address")
}
@@ -92,6 +100,8 @@ func (c *windowsController) Enable(httpHostPort string) error {
}
func (c *windowsController) Disable() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.enabled {
return nil
}
@@ -99,6 +109,8 @@ func (c *windowsController) Disable() error {
}
func (c *windowsController) ForceDisable() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.disableLocked(false)
}
+2 -2
View File
@@ -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.3"
const CurrentVersion = "3.8.0"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 3
const BuildNumber = 1
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"