diff --git a/README.md b/README.md index 9735edc..326888c 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,71 @@ # Navis -Windows-клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) с профилями, пингом серверов и автообновлением. +Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) с профилями, пингом, URL-подпиской и автообновлением. -## Сборка +## Платформы + +| OS | Артефакт | UI | +|----|----------|----| +| Windows amd64 | `dist/navis-release/Navis.exe` | GUI (WebView2) | +| macOS arm64 | `dist/navis-release/darwin-arm64/Navis` | CLI | +| macOS amd64 | `dist/navis-release/darwin-amd64/Navis` | CLI | + +## Сборка Windows ```bat build.bat ``` -Или: +или: ```bat go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp ``` +## Сборка macOS (кросс с Windows / Linux) + +```bat +build-macos.bat +``` + +или: + +```bash +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o dist/navis-release/darwin-arm64/Navis ./cmd/vpnclient +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o dist/navis-release/darwin-amd64/Navis ./cmd/vpnclient +``` + +На Mac: + +```bash +chmod +x Navis +./Navis init +./Navis install-core +./Navis set-proxy 'hysteria2://…' +./Navis connect +``` + +Системный прокси на macOS выставляется через `networksetup` (HTTP + SOCKS). + ## Обновления -Клиент читает манифест из этого репозитория: +Манифест: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json` -- Manifest: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json` -- Binary: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe` +Формат (multi-platform): -Чтобы выкатить релиз: соберите `Navis.exe`, положите в `dist/navis-release/`, обновите `dist/update.json` (version + sha256), сделайте commit и push в `main`. +```json +{ + "version": "1.4.0", + "notes": "…", + "platforms": { + "windows-amd64": { "url": "…/Navis.exe", "sha256": "…" }, + "darwin-arm64": { "url": "…/darwin-arm64/Navis", "sha256": "…" }, + "darwin-amd64": { "url": "…/darwin-amd64/Navis", "sha256": "…" } + } +} +``` + +Клиент сам выбирает ключ `GOOS-GOARCH`. ## Купить доступ diff --git a/build-macos.bat b/build-macos.bat new file mode 100644 index 0000000..3c236f2 --- /dev/null +++ b/build-macos.bat @@ -0,0 +1,21 @@ +@echo off +setlocal +cd /d "%~dp0" + +mkdir "dist\navis-release\darwin-arm64" 2>nul +mkdir "dist\navis-release\darwin-amd64" 2>nul + +set CGO_ENABLED=0 +set GOOS=darwin +set GOARCH=arm64 +go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis" .\cmd\vpnclient +if errorlevel 1 exit /b 1 + +set GOARCH=amd64 +go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis" .\cmd\vpnclient +if errorlevel 1 exit /b 1 + +echo Built: +dir /b "dist\navis-release\darwin-arm64\Navis" +dir /b "dist\navis-release\darwin-amd64\Navis" +endlocal diff --git a/cmd/vpnapp/main_other.go b/cmd/vpnapp/main_other.go index 913d48d..9862e82 100644 --- a/cmd/vpnapp/main_other.go +++ b/cmd/vpnapp/main_other.go @@ -8,6 +8,7 @@ import ( ) func main() { - fmt.Fprintln(os.Stderr, "Navis GUI is Windows-only (WebView2). Use vpnclient CLI on this platform.") + fmt.Fprintln(os.Stderr, "Navis GUI (WebView2) is Windows-only.") + fmt.Fprintln(os.Stderr, "On macOS use the CLI binary: ./Navis connect | install-core | check-update") os.Exit(1) } diff --git a/cmd/vpnclient/main.go b/cmd/vpnclient/main.go index 0671fcb..65d7e90 100644 --- a/cmd/vpnclient/main.go +++ b/cmd/vpnclient/main.go @@ -15,6 +15,7 @@ import ( "vpnclient/internal/core" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" + "vpnclient/internal/update" ) func main() { @@ -43,6 +44,10 @@ func main() { os.Exit(runProbe(args)) case "set-proxy": os.Exit(runSetProxy(args)) + case "check-update": + os.Exit(runCheckUpdate(args)) + case "apply-update": + os.Exit(runApplyUpdate(args)) case "help", "-h", "--help": printUsage() default: @@ -53,21 +58,21 @@ func main() { } func printUsage() { - fmt.Fprintf(os.Stderr, `VPN client (NaiveProxy) for Windows + fmt.Fprintf(os.Stderr, `Navis VPN client (NaiveProxy + Hysteria 2) — Windows / macOS Usage: - vpnclient init [-config configs/config.json] - vpnclient install-core [-config configs/config.json] - vpnclient set-proxy [-config configs/config.json] - vpnclient connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe] - vpnclient probe [-config configs/config.json] [-profile name] [-url URL] + navis init [-config configs/config.json] + navis install-core [-config configs/config.json] + navis set-proxy [-config configs/config.json] + navis connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe] + navis probe [-config configs/config.json] [-profile name] [-url URL] + navis check-update + navis apply-update -Share links like naive+https://user:pass@host:443#name are accepted. +Share links: naive+https://… hysteria2://… hy2://… -Architecture: - This client runs the official naive.exe from klzgrad/naiveproxy (Chromium - network stack) and optionally sets the Windows system HTTP proxy so apps - use the tunnel. That is a real NaiveProxy session, not a simulation. +On macOS system proxy uses networksetup (HTTP + SOCKS). Local listens: + socks://127.0.0.1:1080 http://127.0.0.1:1081 `) } @@ -255,3 +260,38 @@ func runSetProxy(args []string) int { fmt.Println("proxy saved") return 0 } + +func runCheckUpdate(args []string) int { + _ = args + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + st, err := update.Check(ctx, update.DefaultManifestURL) + if err != nil { + fmt.Fprintf(os.Stderr, "check-update: %v\n", err) + return 1 + } + fmt.Printf("current=%s platform=%s latest=%s available=%v\n", st.Current, st.Platform, st.Latest, st.Available) + if st.Notes != "" { + fmt.Println(st.Notes) + } + if st.Error != "" && !st.Available { + fmt.Fprintf(os.Stderr, "note: %s\n", st.Error) + } + if st.Available { + fmt.Printf("url=%s\n", st.URL) + } + return 0 +} + +func runApplyUpdate(args []string) int { + _ = args + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + latest, err := update.Apply(ctx, update.DefaultManifestURL) + if err != nil { + fmt.Fprintf(os.Stderr, "apply-update: %v\n", err) + return 1 + } + fmt.Printf("updating to %s — exit the app to finish\n", latest) + return 0 +} diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index a32d332..610b94d 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/darwin-amd64/Navis b/dist/navis-release/darwin-amd64/Navis new file mode 100644 index 0000000..a011c68 Binary files /dev/null and b/dist/navis-release/darwin-amd64/Navis differ diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis new file mode 100644 index 0000000..0d7c3ca Binary files /dev/null and b/dist/navis-release/darwin-arm64/Navis differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 0a2e373..b4bb977 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,10 +1,30 @@ { - "version": "1.3.1", - "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", + "version": "1.4.0", + "notes": "macOS CLI (arm64/amd64), multi-platform updates, networksetup proxy, naive tar.xz", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", - "mandatory": false + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "mandatory": false, + "platforms": { + "windows-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "os": "windows", + "arch": "amd64" + }, + "darwin-arm64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", + "sha256": "4ce2b429fe4c8e8e1fdf776150003e42e4452e9ecf95cb55a714f84884717fd6", + "os": "darwin", + "arch": "arm64" + }, + "darwin-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", + "sha256": "a91bc03c7c68d896c42bb3520010e96191c12a0e95e2b9c2582dd2732637d9af", + "os": "darwin", + "arch": "amd64" + } + } } diff --git a/dist/update.json b/dist/update.json index 0a2e373..b4bb977 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,10 +1,30 @@ { - "version": "1.3.1", - "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", + "version": "1.4.0", + "notes": "macOS CLI (arm64/amd64), multi-platform updates, networksetup proxy, naive tar.xz", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", - "mandatory": false + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "mandatory": false, + "platforms": { + "windows-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "os": "windows", + "arch": "amd64" + }, + "darwin-arm64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", + "sha256": "4ce2b429fe4c8e8e1fdf776150003e42e4452e9ecf95cb55a714f84884717fd6", + "os": "darwin", + "arch": "arm64" + }, + "darwin-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", + "sha256": "a91bc03c7c68d896c42bb3520010e96191c12a0e95e2b9c2582dd2732637d9af", + "os": "darwin", + "arch": "amd64" + } + } } diff --git a/internal/core/manager.go b/internal/core/manager.go index 8d4eb2d..4b341ff 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "io" "net" @@ -81,8 +82,12 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error { return fmt.Errorf("system_proxy requires an http:// listen address in the profile") } if err := m.sys.Enable(httpProxy); err != nil { - _ = engine.Stop() - return fmt.Errorf("enable system proxy: %w", err) + if errors.Is(err, sysproxy.ErrUnsupported) { + fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n") + } else { + _ = engine.Stop() + return fmt.Errorf("enable system proxy: %w", err) + } } } diff --git a/internal/protocols/hysteria2/binary.go b/internal/protocols/hysteria2/binary.go index dcf5d74..b5dd288 100644 --- a/internal/protocols/hysteria2/binary.go +++ b/internal/protocols/hysteria2/binary.go @@ -40,14 +40,22 @@ func ResolveBinary(binDir string) (string, error) { } func candidateNames() []string { - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": return []string{ "hysteria.exe", "hysteria-windows-amd64.exe", "hysteria-windows-amd64-avx.exe", } + case "darwin": + return []string{ + "hysteria", + "hysteria-darwin-arm64", + "hysteria-darwin-amd64", + } + default: + return []string{"hysteria", "hysteria-linux-amd64", "hysteria-linux-arm64"} } - return []string{"hysteria", "hysteria-linux-amd64"} } // EnsureBinary downloads official Hysteria 2 release if missing. diff --git a/internal/protocols/naive/binary.go b/internal/protocols/naive/binary.go index bec973e..c7290ae 100644 --- a/internal/protocols/naive/binary.go +++ b/internal/protocols/naive/binary.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -54,13 +55,13 @@ func EnsureBinary(binDir string) (string, error) { if err != nil { return "", err } - zipPath := filepath.Join(binDir, assetName) - if err := downloadFile(zipPath, url); err != nil { + archivePath := filepath.Join(binDir, assetName) + if err := downloadFile(archivePath, url); err != nil { return "", err } - defer os.Remove(zipPath) + defer os.Remove(archivePath) - if err := unzipNaive(zipPath, binDir); err != nil { + if err := extractNaiveArchive(archivePath, binDir); err != nil { return "", err } return ResolveBinary(binDir) @@ -119,9 +120,6 @@ func findReleaseAsset(pattern string) (name, downloadURL string, err error) { } for _, a := range rel.Assets { if strings.HasPrefix(a.Name, prefix) && strings.HasSuffix(a.Name, suffix) { - if strings.Contains(a.Name, ".tar.") { - return "", "", fmt.Errorf("auto-extract of %s not implemented; download manually from %s", a.Name, a.BrowserDownloadURL) - } return a.Name, a.BrowserDownloadURL, nil } } @@ -147,6 +145,61 @@ func downloadFile(path, url string) error { return err } +func extractNaiveArchive(archivePath, destDir string) error { + lower := strings.ToLower(archivePath) + switch { + case strings.HasSuffix(lower, ".zip"): + return unzipNaive(archivePath, destDir) + case strings.HasSuffix(lower, ".tar.xz"), strings.HasSuffix(lower, ".txz"): + return extractTarXz(archivePath, destDir) + default: + return fmt.Errorf("unsupported archive: %s", filepath.Base(archivePath)) + } +} + +func extractTarXz(archivePath, destDir string) error { + tmp, err := os.MkdirTemp(destDir, "naive-extract-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + + cmd := exec.Command("tar", "-xJf", archivePath, "-C", tmp) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("tar extract: %w (%s)", err, strings.TrimSpace(string(out))) + } + + want := "naive" + var found string + _ = filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + if info.Name() == want { + found = path + } + return nil + }) + if found == "" { + return fmt.Errorf("archive does not contain %s", want) + } + dest := filepath.Join(destDir, want) + in, err := os.Open(found) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return nil +} + func unzipNaive(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go index 34001e7..b05171e 100644 --- a/internal/subscription/fetch.go +++ b/internal/subscription/fetch.go @@ -39,7 +39,7 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) { if err != nil { return nil, err } - req.Header.Set("User-Agent", "Navis/1.3.1") + req.Header.Set("User-Agent", "Navis/1.4.0") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { diff --git a/internal/sysproxy/darwin.go b/internal/sysproxy/darwin.go new file mode 100644 index 0000000..9fa6109 --- /dev/null +++ b/internal/sysproxy/darwin.go @@ -0,0 +1,143 @@ +//go:build darwin + +package sysproxy + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os/exec" + "strings" + "sync" +) + +type darwinController struct { + mu sync.Mutex + enabled bool + host string + httpPort string + socksPort string + services []string +} + +func newPlatform() Controller { return &darwinController{} } + +func (c *darwinController) Enable(httpHostPort string) error { + c.mu.Lock() + defer c.mu.Unlock() + + host, port, err := net.SplitHostPort(httpHostPort) + if err != nil { + return fmt.Errorf("sysproxy: bad address %q: %w", httpHostPort, err) + } + if host == "" || host == "0.0.0.0" { + host = "127.0.0.1" + } + socks := "1080" + if port == "1081" { + socks = "1080" + } + + services, err := listNetworkServices() + if err != nil { + return err + } + if len(services) == 0 { + return fmt.Errorf("sysproxy: no network services") + } + + for _, svc := range services { + if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil { + return err + } + if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil { + return err + } + if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil { + return err + } + if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil { + return err + } + if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil { + return err + } + if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil { + return err + } + } + + c.enabled = true + c.host = host + c.httpPort = port + c.socksPort = socks + c.services = services + return nil +} + +func (c *darwinController) Disable() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.enabled { + return nil + } + services := c.services + if len(services) == 0 { + var err error + services, err = listNetworkServices() + if err != nil { + return err + } + } + var first error + for _, svc := range services { + for _, args := range [][]string{ + {"-setwebproxystate", svc, "off"}, + {"-setsecurewebproxystate", svc, "off"}, + {"-setsocksfirewallproxystate", svc, "off"}, + } { + if err := runNetworkSetup(args...); err != nil && first == nil { + first = err + } + } + } + c.enabled = false + c.services = nil + return first +} + +func (c *darwinController) Enabled() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.enabled +} + +func listNetworkServices() ([]string, error) { + out, err := exec.Command("networksetup", "-listallnetworkservices").Output() + if err != nil { + return nil, fmt.Errorf("networksetup: %w", err) + } + var services []string + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "An asterisk") { + continue + } + if strings.HasPrefix(line, "*") { + continue // disabled service + } + services = append(services, line) + } + return services, sc.Err() +} + +func runNetworkSetup(args ...string) error { + cmd := exec.Command("networksetup", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("networksetup %v: %w (%s)", args, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/sysproxy/other.go b/internal/sysproxy/other.go index 2dc6b1d..1688305 100644 --- a/internal/sysproxy/other.go +++ b/internal/sysproxy/other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !darwin package sysproxy diff --git a/internal/update/update.go b/internal/update/update.go index 032f177..6fddc4d 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -9,35 +9,48 @@ import ( "io" "net/http" "os" - "os/exec" "path/filepath" + "runtime" "strconv" "strings" - "syscall" "time" ) // CurrentVersion is the shipped client version. -const CurrentVersion = "1.3.1" +const CurrentVersion = "1.4.0" // 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" -// Manifest describes a remote release. -type Manifest struct { - Version string `json:"version"` - URL string `json:"url"` - SHA256 string `json:"sha256,omitempty"` - Notes string `json:"notes,omitempty"` - Mandatory bool `json:"mandatory,omitempty"` +// PlatformAsset is one OS/arch binary in the feed. +type PlatformAsset struct { + URL string `json:"url"` + SHA256 string `json:"sha256,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` } -// Status is returned to the UI. +// Manifest describes a remote release (multi-platform + legacy single URL). +type Manifest struct { + Version string `json:"version"` + URL string `json:"url,omitempty"` // legacy windows-amd64 + SHA256 string `json:"sha256,omitempty"` + Notes string `json:"notes,omitempty"` + Mandatory bool `json:"mandatory,omitempty"` + Platform string `json:"platform,omitempty"` // legacy + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + Platforms map[string]PlatformAsset `json:"platforms,omitempty"` +} + +// Status is returned to the UI / CLI. type Status struct { Current string `json:"current"` Latest string `json:"latest,omitempty"` Notes string `json:"notes,omitempty"` URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Platform string `json:"platform,omitempty"` Available bool `json:"available"` Mandatory bool `json:"mandatory"` Checking bool `json:"checking,omitempty"` @@ -45,42 +58,54 @@ type Status struct { ManifestURL string `json:"manifest_url"` } -// Check fetches the manifest and compares versions. +// PlatformKey is "GOOS-GOARCH", e.g. windows-amd64, darwin-arm64. +func PlatformKey() string { + return runtime.GOOS + "-" + runtime.GOARCH +} + +// AssetFor returns download URL + checksum for the given platform key. +func (m Manifest) AssetFor(key string) (url, sha string, ok bool) { + if key == "" { + key = PlatformKey() + } + if m.Platforms != nil { + if a, found := m.Platforms[key]; found && strings.TrimSpace(a.URL) != "" { + return strings.TrimSpace(a.URL), strings.TrimSpace(a.SHA256), true + } + } + // Legacy single-asset manifests are windows-amd64. + if (key == "windows-amd64" || key == "windows-386") && strings.TrimSpace(m.URL) != "" { + return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), true + } + return "", "", false +} + +// Check fetches the manifest and compares versions for this platform. func Check(ctx context.Context, manifestURL string) (Status, error) { if manifestURL == "" { manifestURL = DefaultManifestURL } - st := Status{Current: CurrentVersion, ManifestURL: manifestURL} + key := PlatformKey() + st := Status{Current: CurrentVersion, ManifestURL: manifestURL, Platform: key} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + m, err := fetchManifest(ctx, manifestURL) if err != nil { - return st, err - } - req.Header.Set("User-Agent", "Navis/"+CurrentVersion) - req.Header.Set("Accept", "application/json") - - client := &http.Client{Timeout: 20 * time.Second} - resp, err := client.Do(req) - if err != nil { - st.Error = err.Error() - return st, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - err = fmt.Errorf("update feed HTTP %d", resp.StatusCode) - st.Error = err.Error() - return st, err - } - var m Manifest - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil { st.Error = err.Error() return st, err } st.Latest = strings.TrimSpace(m.Version) st.Notes = m.Notes - st.URL = m.URL st.Mandatory = m.Mandatory - st.Available = Compare(st.Latest, CurrentVersion) > 0 && m.URL != "" + + url, sha, ok := m.AssetFor(key) + if !ok { + st.Error = fmt.Sprintf("нет сборки для %s", key) + st.Available = false + return st, nil + } + st.URL = url + st.SHA256 = sha + st.Available = Compare(st.Latest, CurrentVersion) > 0 && url != "" return st, nil } @@ -120,7 +145,6 @@ func splitVer(v string) []int { out = append(out, 0) continue } - // strip pre-release suffix: 1.2.3-beta if i := strings.IndexAny(p, "-+"); i >= 0 { p = p[:i] } @@ -130,78 +154,6 @@ func splitVer(v string) []int { return out } -// Apply downloads the new exe and schedules replacement after exit. -func Apply(ctx context.Context, manifestURL string) (string, error) { - st, err := Check(ctx, manifestURL) - if err != nil { - return "", err - } - if !st.Available { - return "", fmt.Errorf("обновление не найдено") - } - if !allowedDownloadURL(st.URL) { - return "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk") - } - - exe, err := os.Executable() - if err != nil { - return "", err - } - exe, err = filepath.Abs(exe) - if err != nil { - return "", err - } - dir := filepath.Dir(exe) - tmp := filepath.Join(dir, "Navis.exe.new") - bat := filepath.Join(dir, "navis-update.bat") - - if err := downloadFile(ctx, st.URL, tmp); err != nil { - return "", err - } - man, err := fetchManifest(ctx, manifestURL) - if err == nil && man.SHA256 != "" { - sum, err := fileSHA256(tmp) - if err != nil { - return "", err - } - if !strings.EqualFold(sum, man.SHA256) { - _ = os.Remove(tmp) - return "", fmt.Errorf("checksum mismatch") - } - } - - // Important: do NOT self-delete the .bat at the end — that causes - // "Не удается найти пакетный файл" on paths with spaces (e.g. D:\vpn navi). - script := "@echo off\r\n" + - "setlocal EnableExtensions\r\n" + - "cd /d \"" + dir + "\"\r\n" + - "set \"EXE=" + exe + "\"\r\n" + - "set \"NEW=" + tmp + "\"\r\n" + - ":wait\r\n" + - "ping -n 2 127.0.0.1 >nul\r\n" + - "del /f /q \"%EXE%\" >nul 2>&1\r\n" + - "if exist \"%EXE%\" goto wait\r\n" + - "move /y \"%NEW%\" \"%EXE%\" >nul\r\n" + - "if not exist \"%EXE%\" exit /b 1\r\n" + - "start \"\" \"%EXE%\"\r\n" + - "exit /b 0\r\n" - if err := os.WriteFile(bat, []byte(script), 0o755); err != nil { - return "", err - } - - // Detached cmd so Apply returns while updater keeps running. - cmd := exec.Command("cmd.exe", "/C", bat) - cmd.Dir = dir - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP - } - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("start updater: %w", err) - } - return st.Latest, nil -} - func allowedDownloadURL(u string) bool { u = strings.ToLower(strings.TrimSpace(u)) return strings.HasPrefix(u, "https://git.evilfox.cc/") || @@ -217,15 +169,21 @@ func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) { return Manifest{}, err } req.Header.Set("User-Agent", "Navis/"+CurrentVersion) + req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { return Manifest{}, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Manifest{}, fmt.Errorf("update feed HTTP %d", resp.StatusCode) + } var m Manifest - err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m) - return m, err + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil { + return Manifest{}, err + } + return m, nil } func downloadFile(ctx context.Context, url, dest string) error { @@ -264,3 +222,51 @@ func fileSHA256(path string) (string, error) { } return hex.EncodeToString(h.Sum(nil)), nil } + +func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) { + st, err := Check(ctx, manifestURL) + if err != nil { + return "", "", "", "", "", err + } + if !st.Available { + if st.Error != "" { + return "", "", "", "", "", fmt.Errorf("%s", st.Error) + } + return "", "", "", "", "", fmt.Errorf("обновление не найдено") + } + if !allowedDownloadURL(st.URL) { + return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk") + } + exe, err := os.Executable() + if err != nil { + return "", "", "", "", "", err + } + exe, err = filepath.Abs(exe) + if err != nil { + return "", "", "", "", "", err + } + tmp := exe + ".new" + if err := downloadFile(ctx, st.URL, tmp); err != nil { + return "", "", "", "", "", err + } + wantSHA := st.SHA256 + if wantSHA == "" { + if man, err := fetchManifest(ctx, manifestURL); err == nil { + if _, sha2, ok := man.AssetFor(PlatformKey()); ok { + wantSHA = sha2 + } + } + } + if wantSHA != "" { + sum, err := fileSHA256(tmp) + if err != nil { + _ = os.Remove(tmp) + return "", "", "", "", "", err + } + if !strings.EqualFold(sum, wantSHA) { + _ = os.Remove(tmp) + return "", "", "", "", "", fmt.Errorf("checksum mismatch") + } + } + return st.Latest, st.URL, wantSHA, exe, tmp, nil +} diff --git a/internal/update/update_darwin.go b/internal/update/update_darwin.go new file mode 100644 index 0000000..4b2f268 --- /dev/null +++ b/internal/update/update_darwin.go @@ -0,0 +1,50 @@ +//go:build darwin + +package update + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// Apply downloads the new binary and schedules replacement after exit (macOS). +func Apply(ctx context.Context, manifestURL string) (string, error) { + latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL) + if err != nil { + return "", err + } + _ = os.Chmod(tmp, 0o755) + dir := filepath.Dir(exe) + scriptPath := filepath.Join(dir, "navis-update.sh") + pid := os.Getpid() + + script := "#!/bin/bash\n" + + "set -e\n" + + "EXE=" + shellQuote(exe) + "\n" + + "NEW=" + shellQuote(tmp) + "\n" + + "PID=" + strconv.Itoa(pid) + "\n" + + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" + + "sleep 0.5\n" + + "mv -f \"$NEW\" \"$EXE\"\n" + + "chmod +x \"$EXE\"\n" + + "rm -f " + shellQuote(scriptPath) + "\n" + + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + return "", err + } + cmd := exec.Command("/bin/bash", scriptPath) + cmd.Dir = dir + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start updater: %w", err) + } + return latest, nil +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/update/update_other.go b/internal/update/update_other.go new file mode 100644 index 0000000..c7e4a33 --- /dev/null +++ b/internal/update/update_other.go @@ -0,0 +1,15 @@ +//go:build !windows && !darwin + +package update + +import ( + "context" + "fmt" +) + +// Apply is not implemented on this OS yet. +func Apply(ctx context.Context, manifestURL string) (string, error) { + _ = ctx + _ = manifestURL + return "", fmt.Errorf("автообновление пока только для Windows и macOS") +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go index d75107d..2b3a149 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -1,6 +1,9 @@ package update -import "testing" +import ( + "strings" + "testing" +) func TestCompare(t *testing.T) { if Compare("1.1.0", "1.0.0") <= 0 { @@ -13,3 +16,28 @@ func TestCompare(t *testing.T) { t.Fatal("expected equal") } } + +func TestManifestAssetFor(t *testing.T) { + m := Manifest{ + URL: "https://git.evilfox.cc/legacy.exe", + SHA256: "abc", + Platforms: map[string]PlatformAsset{ + "darwin-arm64": { + URL: "https://git.evilfox.cc/arm64/Navis", + SHA256: "def", + }, + }, + } + u, s, ok := m.AssetFor("darwin-arm64") + if !ok || u == "" || s != "def" { + t.Fatalf("darwin: %v %q %q", ok, u, s) + } + u, s, ok = m.AssetFor("windows-amd64") + if !ok || !strings.Contains(u, "legacy") || s != "abc" { + t.Fatalf("windows legacy: %v %q %q", ok, u, s) + } + _, _, ok = m.AssetFor("linux-amd64") + if ok { + t.Fatal("expected missing linux") + } +} diff --git a/internal/update/update_windows.go b/internal/update/update_windows.go new file mode 100644 index 0000000..989b2b3 --- /dev/null +++ b/internal/update/update_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package update + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// Apply downloads the new exe and schedules replacement after exit (Windows). +func Apply(ctx context.Context, manifestURL string) (string, error) { + latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL) + if err != nil { + return "", err + } + dir := filepath.Dir(exe) + bat := filepath.Join(dir, "navis-update.bat") + + script := "@echo off\r\n" + + "setlocal EnableExtensions\r\n" + + "cd /d \"" + dir + "\"\r\n" + + "set \"EXE=" + exe + "\"\r\n" + + "set \"NEW=" + tmp + "\"\r\n" + + ":wait\r\n" + + "ping -n 2 127.0.0.1 >nul\r\n" + + "del /f /q \"%EXE%\" >nul 2>&1\r\n" + + "if exist \"%EXE%\" goto wait\r\n" + + "move /y \"%NEW%\" \"%EXE%\" >nul\r\n" + + "if not exist \"%EXE%\" exit /b 1\r\n" + + "start \"\" \"%EXE%\"\r\n" + + "exit /b 0\r\n" + if err := os.WriteFile(bat, []byte(script), 0o755); err != nil { + return "", err + } + + cmd := exec.Command("cmd.exe", "/C", bat) + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start updater: %w", err) + } + return latest, nil +} diff --git a/server/README.md b/server/README.md index 658c135..6066eac 100644 --- a/server/README.md +++ b/server/README.md @@ -2,17 +2,23 @@ Обновления отдаются из git-репозитория: -- `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json` -- `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe` +- Manifest: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json` +- Windows: `…/dist/navis-release/Navis.exe` +- macOS arm64: `…/dist/navis-release/darwin-arm64/Navis` +- macOS amd64: `…/dist/navis-release/darwin-amd64/Navis` -Пример `dist/update.json`: +Пример `dist/update.json` (multi-platform + legacy url для старых клиентов): ```json { - "version": "1.1.2", + "version": "1.4.0", "notes": "Что нового", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "hex", - "mandatory": false + "sha256": "hex-windows", + "platforms": { + "windows-amd64": { "url": "…/Navis.exe", "sha256": "…" }, + "darwin-arm64": { "url": "…/darwin-arm64/Navis", "sha256": "…" }, + "darwin-amd64": { "url": "…/darwin-amd64/Navis", "sha256": "…" } + } } ``` diff --git a/server/update.json b/server/update.json index 0a2e373..b4bb977 100644 --- a/server/update.json +++ b/server/update.json @@ -1,10 +1,30 @@ { - "version": "1.3.1", - "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", + "version": "1.4.0", + "notes": "macOS CLI (arm64/amd64), multi-platform updates, networksetup proxy, naive tar.xz", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", - "mandatory": false + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "mandatory": false, + "platforms": { + "windows-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "110ab8f5806f2635b39ec586d8218120d0041a82212715530ae8c3838078bf11", + "os": "windows", + "arch": "amd64" + }, + "darwin-arm64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", + "sha256": "4ce2b429fe4c8e8e1fdf776150003e42e4452e9ecf95cb55a714f84884717fd6", + "os": "darwin", + "arch": "arm64" + }, + "darwin-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", + "sha256": "a91bc03c7c68d896c42bb3520010e96191c12a0e95e2b9c2582dd2732637d9af", + "os": "darwin", + "arch": "amd64" + } + } } diff --git a/versioninfo.json b/versioninfo.json index db0ef99..e579216 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 }, - "ProductVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 }, + "FileVersion": { "Major": 1, "Minor": 4, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 1, "Minor": 4, "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -10,12 +10,12 @@ }, "StringFileInfo": { "CompanyName": "Navis", - "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows", - "FileVersion": "1.3.1.0", + "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows and macOS", + "FileVersion": "1.4.0.0", "InternalName": "Navis", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "1.3.1.0" + "ProductVersion": "1.4.0.0" }, "VarFileInfo": { "Translation": {