commit 595bc3d70d9a14e04728d7620585d98d826d3167 Author: Navis Date: Tue Jul 28 06:55:19 2026 +0300 Initial Navis client with NaiveProxy, profiles, ping and git updates Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5c9244 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +bin/ +*.exe +!dist/ +!dist/**/*.exe +!dist/**/ +configs/config.json +configs/.navis-webview/ +cmd/vpnapp/resource.syso +*.syso +.navis-webview/ +navis-update.bat +Navis.exe.new diff --git a/README.md b/README.md new file mode 100644 index 0000000..9735edc --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Navis + +Windows-клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) с профилями, пингом серверов и автообновлением. + +## Сборка + +```bat +build.bat +``` + +Или: + +```bat +go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp +``` + +## Обновления + +Клиент читает манифест из этого репозитория: + +- 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` + +Чтобы выкатить релиз: соберите `Navis.exe`, положите в `dist/navis-release/`, обновите `dist/update.json` (version + sha256), сделайте commit и push в `main`. + +## Купить доступ + +https://evilfox.win/ diff --git a/assets/navis-icon.png b/assets/navis-icon.png new file mode 100644 index 0000000..c011784 Binary files /dev/null and b/assets/navis-icon.png differ diff --git a/assets/navis.ico b/assets/navis.ico new file mode 100644 index 0000000..9cd0fc4 Binary files /dev/null and b/assets/navis.ico differ diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..c18d0d2 --- /dev/null +++ b/build.bat @@ -0,0 +1,29 @@ +@echo off +setlocal +cd /d "%~dp0" + +echo Generating Windows icon resources... +go run ./tools/mkico +if errorlevel 1 exit /b 1 + +where goversioninfo >nul 2>&1 +if errorlevel 1 ( + go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest +) + +goversioninfo -icon assets\navis.ico -o cmd\vpnapp\resource.syso versioninfo.json +if errorlevel 1 exit /b 1 + +echo Building Navis GUI and CLI... +go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp +if errorlevel 1 exit /b 1 + +go build -ldflags="-s -w" -o vpnclient.exe ./cmd/vpnclient +if errorlevel 1 exit /b 1 + +echo. +echo Done: +echo Navis.exe +echo vpnclient.exe +echo assets\navis.ico +endlocal diff --git a/cmd/vpnapp/main_other.go b/cmd/vpnapp/main_other.go new file mode 100644 index 0000000..913d48d --- /dev/null +++ b/cmd/vpnapp/main_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "Navis GUI is Windows-only (WebView2). Use vpnclient CLI on this platform.") + os.Exit(1) +} diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go new file mode 100644 index 0000000..e6505cd --- /dev/null +++ b/cmd/vpnapp/main_windows.go @@ -0,0 +1,385 @@ +//go:build windows + +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/jchv/go-webview2" + "golang.org/x/sys/windows" + + "vpnclient/internal/appui" + "vpnclient/internal/config" + "vpnclient/internal/core" + "vpnclient/internal/netcheck" + "vpnclient/internal/protocols/naive" + "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"` +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + cfgPath, err := config.LocateConfig() + if err != nil { + fatalDialog("Не удалось найти путь конфига: %v", err) + } + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := config.WriteExample(cfgPath); err != nil { + fatalDialog("Не удалось создать конфиг: %v", err) + } + } + + cfg, err := config.Load(cfgPath) + if err != nil { + fatalDialog("Ошибка конфига %s: %v", cfgPath, err) + } + + logBuf := &bytes.Buffer{} + mgr, err := core.NewManager(cfgPath, cfg, logBuf) + if err != nil { + fatalDialog("%v", err) + } + + a := &app{ + mgr: mgr, + cfgPath: cfgPath, + logBuf: logBuf, + updateStatus: update.Status{ + Current: update.CurrentVersion, + ManifestURL: update.DefaultManifestURL, + }, + } + + dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview") + _ = os.MkdirAll(dataPath, 0o755) + + go func() { + if _, err := naive.ResolveBinary(mgr.BinDir()); err == nil { + return + } + _, _ = naive.EnsureBinary(mgr.BinDir()) + }() + + w := webview2.NewWithOptions(webview2.WebViewOptions{ + Debug: false, + AutoFocus: true, + DataPath: dataPath, + WindowOptions: webview2.WindowOptions{ + Title: "Navis", + Width: 520, + Height: 860, + Center: true, + IconId: 1, + }, + }) + if w == nil { + fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.") + } + a.webview = w + defer func() { + _ = a.mgr.Disconnect() + w.Destroy() + }() + + applyWindowIcon(w.Window()) + w.SetSize(520, 860, webview2.HintNone) + + mustBind(w, "getState", a.getState) + mustBind(w, "connect", a.connect) + mustBind(w, "disconnect", a.disconnect) + mustBind(w, "saveProfile", a.saveProfile) + mustBind(w, "createProfile", a.createProfile) + mustBind(w, "selectProfile", a.selectProfile) + mustBind(w, "deleteProfile", a.deleteProfile) + mustBind(w, "installCore", a.installCore) + mustBind(w, "openURL", openURL) + mustBind(w, "pingServers", a.pingServers) + mustBind(w, "checkUpdate", a.checkUpdate) + mustBind(w, "applyUpdate", a.applyUpdate) + + go a.autoCheckUpdate() + + w.SetHtml(appui.IndexHTML) + w.Run() +} + +func mustBind(w webview2.WebView, name string, fn interface{}) { + if err := w.Bind(name, fn); err != nil { + fatalDialog("bind %s: %v", name, err) + } +} + +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, coreErr := naive.ResolveBinary(a.mgr.BinDir()) + 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: coreErr == nil, + CorePath: corePath, + ConfigPath: a.cfgPath, + Profiles: cfg.ListProfiles(), + Version: update.CurrentVersion, + Update: a.updateStatus, + Pings: append([]netcheck.Result(nil), a.pings...), + } + 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 { + a.mu.Lock() + defer a.mu.Unlock() + + if p, err := a.mgr.Config().ActiveProfile(); err != nil { + return err + } else if strings.TrimSpace(p.Proxy) == "" { + return fmt.Errorf("сначала вставьте ссылку сервера") + } + + if _, err := naive.EnsureBinary(a.mgr.BinDir()); 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 { + a.mu.Lock() + defer a.mu.Unlock() + return a.mgr.Disconnect() +} + +func (a *app) installCore() (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + return naive.EnsureBinary(a.mgr.BinDir()) +} + +func (a *app) pingServers() ([]netcheck.Result, error) { + a.mu.Lock() + list := a.mgr.Profiles() + a.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + out := make([]netcheck.Result, 0, len(list)) + for _, p := range list { + proxy := p.Proxy + if proxy == "" { + out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"}) + continue + } + out = append(out, netcheck.PingProxyURI(ctx, p.Name, proxy)) + } + + 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.CurrentVersion + st.ManifestURL = update.DefaultManifestURL + st.Error = err.Error() + } + a.updateStatus = st + a.mu.Unlock() + return st, nil +} + +func (a *app) applyUpdate() error { + 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() + go func() { + time.Sleep(400 * time.Millisecond) + if a.webview != nil { + a.webview.Terminate() + } + os.Exit(0) + }() + _ = latest + return nil +} + +func (a *app) autoCheckUpdate() { + time.Sleep(2 * time.Second) + _, _ = a.checkUpdate() + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for range ticker.C { + _, _ = a.checkUpdate() + } +} + +func openURL(raw string) error { + raw = strings.TrimSpace(raw) + switch raw { + case "https://evilfox.win/", "https://evilfox.win", "http://evilfox.win/", "http://evilfox.win": + raw = "https://evilfox.win/" + default: + return fmt.Errorf("разрешена только ссылка evilfox.win") + } + cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", raw) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd.Start() +} + +func applyWindowIcon(hwnd unsafe.Pointer) { + ico := findIconPath() + if ico == "" || hwnd == nil { + return + } + pathPtr, err := windows.UTF16PtrFromString(ico) + if err != nil { + return + } + user32 := windows.NewLazySystemDLL("user32.dll") + loadImage := user32.NewProc("LoadImageW") + sendMessage := user32.NewProc("SendMessageW") + const ( + imageIcon = 1 + lrLoadFromFile = 0x0010 + lrDefaultSize = 0x0040 + wmSetIcon = 0x0080 + iconSmall = 0 + iconBig = 1 + ) + h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize) + if h == 0 { + return + } + sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h) + sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h) +} + +func findIconPath() string { + candidates := []string{} + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + candidates = append(candidates, + filepath.Join(dir, "assets", "navis.ico"), + filepath.Join(dir, "navis.ico"), + ) + } + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico")) + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +func fatalDialog(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + log.Println(msg) + messageBox(msg) + os.Exit(1) +} diff --git a/cmd/vpnapp/msgbox_windows.go b/cmd/vpnapp/msgbox_windows.go new file mode 100644 index 0000000..2a77543 --- /dev/null +++ b/cmd/vpnapp/msgbox_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +func messageBox(text string) { + user32 := syscall.NewLazyDLL("user32.dll") + proc := user32.NewProc("MessageBoxW") + title, _ := syscall.UTF16PtrFromString("Navis") + body, _ := syscall.UTF16PtrFromString(text) + proc.Call(0, uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(title)), 0x10) // MB_ICONERROR +} diff --git a/cmd/vpnclient/main.go b/cmd/vpnclient/main.go new file mode 100644 index 0000000..2dc538a --- /dev/null +++ b/cmd/vpnclient/main.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "vpnclient/internal/config" + "vpnclient/internal/core" + "vpnclient/internal/protocols/naive" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(2) + } + + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "connect": + os.Exit(runConnect(args)) + case "disconnect": + fmt.Fprintln(os.Stderr, "disconnect: use Ctrl+C on a running connect session") + os.Exit(2) + case "status": + fmt.Fprintln(os.Stderr, "status: only available while connect is running in this process") + os.Exit(2) + case "install-core": + os.Exit(runInstallCore(args)) + case "init": + os.Exit(runInit(args)) + case "probe": + os.Exit(runProbe(args)) + case "set-proxy": + os.Exit(runSetProxy(args)) + case "help", "-h", "--help": + printUsage() + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + printUsage() + os.Exit(2) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `VPN client (NaiveProxy) for Windows + +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] + +Share links like naive+https://user:pass@host:443#name are accepted. + +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. + +`) +} + +func defaultConfigPath() string { + return filepath.Join("configs", "config.json") +} + +func runInit(args []string) int { + fs := flag.NewFlagSet("init", flag.ExitOnError) + cfgPath := fs.String("config", defaultConfigPath(), "path to write example config") + _ = fs.Parse(args) + if _, err := os.Stat(*cfgPath); err == nil { + fmt.Fprintf(os.Stderr, "refusing to overwrite existing %s\n", *cfgPath) + return 1 + } + if err := config.WriteExample(*cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "init: %v\n", err) + return 1 + } + fmt.Printf("wrote %s — edit proxy user/pass/host, then: vpnclient install-core && vpnclient connect\n", *cfgPath) + return 0 +} + +func runInstallCore(args []string) int { + fs := flag.NewFlagSet("install-core", flag.ExitOnError) + cfgPath := fs.String("config", defaultConfigPath(), "config path (for bin_dir)") + binDirFlag := fs.String("bin-dir", "", "override bin directory") + _ = fs.Parse(args) + + binDir := *binDirFlag + if binDir == "" { + if cfg, err := config.Load(*cfgPath); err == nil { + resolved, err := config.ResolveBinDir(*cfgPath, cfg.BinDir) + if err != nil { + fmt.Fprintf(os.Stderr, "install-core: %v\n", err) + return 1 + } + binDir = resolved + } else { + binDir = "bin" + } + } + path, err := naive.EnsureBinary(binDir) + if err != nil { + fmt.Fprintf(os.Stderr, "install-core: %v\n", err) + return 1 + } + fmt.Printf("naive core ready: %s\n", path) + return 0 +} + +func loadManager(cfgPath string) (*core.Manager, *config.Config, error) { + cfg, err := config.Load(cfgPath) + if err != nil { + return nil, nil, err + } + mgr, err := core.NewManager(cfgPath, cfg, os.Stderr) + if err != nil { + return nil, nil, err + } + return mgr, cfg, nil +} + +func runConnect(args []string) int { + fs := flag.NewFlagSet("connect", flag.ExitOnError) + cfgPath := fs.String("config", defaultConfigPath(), "config path") + profile := fs.String("profile", "", "profile name (default: config.active)") + noSys := fs.Bool("no-sysproxy", false, "do not change Windows system proxy") + doProbe := fs.Bool("probe", false, "verify tunnel with an HTTP request after connect") + _ = fs.Parse(args) + + mgr, cfg, err := loadManager(*cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + return 1 + } + if *noSys { + cfg.SystemProxy = false + } + + // Ensure official core is present. + if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + return 1 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Connect(ctx, *profile); err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + return 1 + } + defer func() { + if err := mgr.Disconnect(); err != nil { + fmt.Fprintf(os.Stderr, "disconnect: %v\n", err) + } + }() + + st := mgr.Status() + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(st) + fmt.Fprintln(os.Stderr, "connected — press Ctrl+C to disconnect and restore system proxy") + + if *doProbe { + pctx, cancel := context.WithTimeout(ctx, 45*time.Second) + err := mgr.Probe(pctx, "") + cancel() + if err != nil { + fmt.Fprintf(os.Stderr, "probe failed: %v\n", err) + } else { + fmt.Fprintln(os.Stderr, "probe ok") + } + } + + <-ctx.Done() + fmt.Fprintln(os.Stderr, "shutting down...") + return 0 +} + +func runProbe(args []string) int { + fs := flag.NewFlagSet("probe", flag.ExitOnError) + cfgPath := fs.String("config", defaultConfigPath(), "config path") + profile := fs.String("profile", "", "profile name") + testURL := fs.String("url", "https://www.google.com/generate_204", "URL to fetch via tunnel") + _ = fs.Parse(args) + + mgr, cfg, err := loadManager(*cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "probe: %v\n", err) + return 1 + } + cfg.SystemProxy = false + + if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil { + fmt.Fprintf(os.Stderr, "probe: %v\n", err) + return 1 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Connect(ctx, *profile); err != nil { + fmt.Fprintf(os.Stderr, "probe: connect: %v\n", err) + return 1 + } + defer mgr.Disconnect() + + pctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + if err := mgr.Probe(pctx, *testURL); err != nil { + fmt.Fprintf(os.Stderr, "probe failed: %v\n", err) + return 1 + } + fmt.Println("probe ok") + return 0 +} + +func runSetProxy(args []string) int { + fs := flag.NewFlagSet("set-proxy", flag.ExitOnError) + cfgPath := fs.String("config", defaultConfigPath(), "config path") + _ = fs.Parse(args) + rest := fs.Args() + if len(rest) < 1 { + fmt.Fprintln(os.Stderr, "usage: vpnclient set-proxy ") + return 2 + } + mgr, _, err := loadManager(*cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "set-proxy: %v\n", err) + return 1 + } + if err := mgr.UpdateProxyURI(rest[0]); err != nil { + fmt.Fprintf(os.Stderr, "set-proxy: %v\n", err) + return 1 + } + fmt.Println("proxy saved") + return 0 +} diff --git a/configs/config.example.json b/configs/config.example.json new file mode 100644 index 0000000..c0f8dd8 --- /dev/null +++ b/configs/config.example.json @@ -0,0 +1,16 @@ +{ + "active": "naive-main", + "system_proxy": true, + "bin_dir": "bin", + "profiles": [ + { + "name": "naive-main", + "protocol": "naive", + "proxy": "", + "listen": [ + "socks://127.0.0.1:1080", + "http://127.0.0.1:1081" + ] + } + ] +} diff --git a/dist/navis-1.1.0.zip b/dist/navis-1.1.0.zip new file mode 100644 index 0000000..ab3058f Binary files /dev/null and b/dist/navis-1.1.0.zip differ diff --git a/dist/navis-1.1.1.zip b/dist/navis-1.1.1.zip new file mode 100644 index 0000000..f81cdf0 Binary files /dev/null and b/dist/navis-1.1.1.zip differ diff --git a/dist/navis-1.1.2.zip b/dist/navis-1.1.2.zip new file mode 100644 index 0000000..2379487 Binary files /dev/null and b/dist/navis-1.1.2.zip differ diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe new file mode 100644 index 0000000..5b8b7c5 Binary files /dev/null and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json new file mode 100644 index 0000000..075a1eb --- /dev/null +++ b/dist/navis-release/update.json @@ -0,0 +1,7 @@ +{ + "version": "1.1.2", + "notes": "Обновления через git.evilfox.cc", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85", + "mandatory": false +} \ No newline at end of file diff --git a/dist/update.json b/dist/update.json new file mode 100644 index 0000000..075a1eb --- /dev/null +++ b/dist/update.json @@ -0,0 +1,7 @@ +{ + "version": "1.1.2", + "notes": "Обновления через git.evilfox.cc", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85", + "mandatory": false +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..83f0e4e --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module vpnclient + +go 1.25.0 + +require ( + github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 + golang.org/x/image v0.44.0 + golang.org/x/sys v0.33.0 +) + +require github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..002bbd5 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/appui/embed.go b/internal/appui/embed.go new file mode 100644 index 0000000..16cdd7e --- /dev/null +++ b/internal/appui/embed.go @@ -0,0 +1,7 @@ +package appui + +import _ "embed" + + +//go:embed index.html +var IndexHTML string diff --git a/internal/appui/index.html b/internal/appui/index.html new file mode 100644 index 0000000..f330bd9 --- /dev/null +++ b/internal/appui/index.html @@ -0,0 +1,625 @@ + + + + + + Navis + + + + + + +
+
+ Доступно обновление +

+ +
+ +
+ +
+

Navis

+

NaiveProxy · несколько серверов

+
+
+ +
+ + Отключено +
+ + +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ +
+ Системный прокси Windows + +
+ +
+ + +
+ + +
+ +
+
+

Загрузка…

+ +
+

Безопасное подключение

+

На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.

+ + https://evilfox.win/ +
+

Navis

+
+ + + + + + diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7f3b5f7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,283 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Protocol identifies a VPN/proxy backend. +type Protocol string + +const ( + ProtocolNaive Protocol = "naive" +) + +// Config is the top-level client configuration. +type Config struct { + // Active is the name of the profile to use by default. + Active string `json:"active"` + + // SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect. + SystemProxy bool `json:"system_proxy"` + + // BinDir is where protocol binaries (naive.exe) are stored. + BinDir string `json:"bin_dir,omitempty"` + + Profiles []Profile `json:"profiles"` +} + +// Profile describes one connection endpoint. +type Profile struct { + Name string `json:"name"` + Protocol Protocol `json:"protocol"` + + // Naive: full proxy URI, e.g. https://user:pass@example.com or quic://... + Proxy string `json:"proxy,omitempty"` + + // Local listen URIs passed to naive. Default: socks + http for system proxy. + Listen []string `json:"listen,omitempty"` + + // Extra Naive options + InsecureConcurrency int `json:"insecure_concurrency,omitempty"` + ExtraHeaders string `json:"extra_headers,omitempty"` + HostResolverRules string `json:"host_resolver_rules,omitempty"` + NoPostQuantum bool `json:"no_post_quantum,omitempty"` + Log string `json:"log,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// DefaultListen returns listen addresses suitable for Windows system proxy. +func (p Profile) DefaultListen() []string { + if len(p.Listen) > 0 { + return p.Listen + } + return []string{ + "socks://127.0.0.1:1080", + "http://127.0.0.1:1081", + } +} + +// HTTPListenHostPort returns host:port of the first http:// listen URI, if any. +func (p Profile) HTTPListenHostPort() (string, bool) { + for _, u := range p.DefaultListen() { + if hostPort, ok := parseListenHostPort(u, "http"); ok { + return hostPort, true + } + } + return "", false +} + +// SOCKSListenHostPort returns host:port of the first socks:// listen URI, if any. +func (p Profile) SOCKSListenHostPort() (string, bool) { + for _, u := range p.DefaultListen() { + if hostPort, ok := parseListenHostPort(u, "socks"); ok { + return hostPort, true + } + } + return "", false +} + +func parseListenHostPort(uri, wantScheme string) (string, bool) { + // Minimal parse: scheme://[user:pass@]host:port + schemeSep := "://" + i := index(uri, schemeSep) + if i < 0 { + return "", false + } + scheme := uri[:i] + if scheme != wantScheme { + return "", false + } + rest := uri[i+len(schemeSep):] + if at := lastIndex(rest, "@"); at >= 0 { + rest = rest[at+1:] + } + if rest == "" { + return "", false + } + return rest, true +} + +func index(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +func lastIndex(s, substr string) int { + for i := len(s) - len(substr); i >= 0; i-- { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// Load reads and validates a config file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + data = stripUTF8BOM(data) + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if cfg.BinDir == "" { + cfg.BinDir = "bin" + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +func stripUTF8BOM(data []byte) []byte { + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + return data[3:] + } + return data +} + +// Validate checks required fields. +func (c *Config) Validate() error { + if len(c.Profiles) == 0 { + return fmt.Errorf("config: no profiles defined") + } + if c.Active == "" { + c.Active = c.Profiles[0].Name + } + if _, err := c.ActiveProfile(); err != nil { + return err + } + for i, p := range c.Profiles { + if p.Name == "" { + return fmt.Errorf("config: profile[%d] missing name", i) + } + switch p.Protocol { + case ProtocolNaive: + // Proxy may be empty until the user pastes a share link in the UI. + default: + return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol) + } + } + return nil +} + +// ActiveProfile returns the selected profile. +func (c *Config) ActiveProfile() (*Profile, error) { + for i := range c.Profiles { + if c.Profiles[i].Name == c.Active { + return &c.Profiles[i], nil + } + } + return nil, fmt.Errorf("config: active profile %q not found", c.Active) +} + +// ResolveBinDir returns an absolute bin directory. +// Relative paths are resolved next to the executable (portable install layout). +func ResolveBinDir(cfgPath, binDir string) (string, error) { + if filepath.IsAbs(binDir) { + return binDir, nil + } + if exe, err := os.Executable(); err == nil { + return filepath.Abs(filepath.Join(filepath.Dir(exe), binDir)) + } + base := filepath.Dir(cfgPath) + if base == "." || base == "" { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + base = cwd + } else if filepath.Base(base) == "configs" { + base = filepath.Dir(base) + } + return filepath.Abs(filepath.Join(base, binDir)) +} + +// Example returns a starter configuration. +func Example() Config { + return Config{ + Active: "naive-main", + SystemProxy: true, + BinDir: "bin", + Profiles: []Profile{ + { + Name: "naive-main", + Protocol: ProtocolNaive, + Proxy: "", + Listen: []string{ + "socks://127.0.0.1:1080", + "http://127.0.0.1:1081", + }, + }, + }, + } +} + +// WriteExample writes example config to path. +func WriteExample(path string) error { + return Save(path, Example()) +} + +// Save writes config as indented JSON. +func Save(path string, cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + return os.WriteFile(path, data, 0o600) +} + +// SetActiveProxy updates the active profile proxy URI. +func (c *Config) SetActiveProxy(proxy string) error { + for i := range c.Profiles { + if c.Profiles[i].Name == c.Active { + c.Profiles[i].Proxy = proxy + return nil + } + } + return fmt.Errorf("active profile %q not found", c.Active) +} + +// LocateConfig finds configs/config.json next to the executable or in cwd. +func LocateConfig() (string, error) { + candidates := []string{} + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + candidates = append(candidates, + filepath.Join(dir, "configs", "config.json"), + filepath.Join(dir, "config.json"), + ) + } + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, "configs", "config.json")) + } + for _, p := range candidates { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + // Default path next to executable for first-run create. + if exe, err := os.Executable(); err == nil { + return filepath.Join(filepath.Dir(exe), "configs", "config.json"), nil + } + return filepath.Join("configs", "config.json"), nil +} diff --git a/internal/config/profiles.go b/internal/config/profiles.go new file mode 100644 index 0000000..e4ccb9f --- /dev/null +++ b/internal/config/profiles.go @@ -0,0 +1,148 @@ +package config + +import ( + "fmt" + "strings" +) + +// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns). +type ProfileInfo struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Proxy string `json:"proxy"` + Host string `json:"host"` + Active bool `json:"active"` +} + +// ListProfiles returns UI-friendly profile summaries. +func (c *Config) ListProfiles() []ProfileInfo { + out := make([]ProfileInfo, 0, len(c.Profiles)) + for _, p := range c.Profiles { + out = append(out, ProfileInfo{ + Name: p.Name, + Protocol: string(p.Protocol), + Proxy: p.Proxy, + Host: proxyHost(p.Proxy), + Active: p.Name == c.Active, + }) + } + return out +} + +func proxyHost(proxy string) string { + proxy = strings.TrimSpace(proxy) + if proxy == "" { + return "" + } + if i := strings.Index(proxy, "://"); i >= 0 { + proxy = proxy[i+3:] + } + if at := strings.LastIndex(proxy, "@"); at >= 0 { + proxy = proxy[at+1:] + } + if i := strings.IndexAny(proxy, "/?#"); i >= 0 { + proxy = proxy[:i] + } + return proxy +} + +// SetActive switches the active profile name. +func (c *Config) SetActive(name string) error { + name = strings.TrimSpace(name) + for _, p := range c.Profiles { + if p.Name == name { + c.Active = name + return nil + } + } + return fmt.Errorf("profile %q not found", name) +} + +// UpsertProfile creates or updates a profile by name. +func (c *Config) UpsertProfile(name, proxy string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("имя профиля пустое") + } + proxy = strings.TrimSpace(proxy) + + for i := range c.Profiles { + if c.Profiles[i].Name == name { + c.Profiles[i].Proxy = proxy + if c.Profiles[i].Protocol == "" { + c.Profiles[i].Protocol = ProtocolNaive + } + if len(c.Profiles[i].Listen) == 0 { + c.Profiles[i].Listen = defaultListen() + } + c.Active = name + return nil + } + } + + c.Profiles = append(c.Profiles, Profile{ + Name: name, + Protocol: ProtocolNaive, + Proxy: proxy, + Listen: defaultListen(), + }) + c.Active = name + return nil +} + +// RenameProfile renames a profile and keeps active pointer in sync. +func (c *Config) RenameProfile(oldName, newName string) error { + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return fmt.Errorf("имя профиля пустое") + } + if oldName == newName { + return nil + } + for _, p := range c.Profiles { + if p.Name == newName { + return fmt.Errorf("профиль %q уже существует", newName) + } + } + for i := range c.Profiles { + if c.Profiles[i].Name == oldName { + c.Profiles[i].Name = newName + if c.Active == oldName { + c.Active = newName + } + return nil + } + } + return fmt.Errorf("profile %q not found", oldName) +} + +// DeleteProfile removes a profile. Keeps at least one profile. +func (c *Config) DeleteProfile(name string) error { + name = strings.TrimSpace(name) + if len(c.Profiles) <= 1 { + return fmt.Errorf("нельзя удалить последний профиль") + } + idx := -1 + for i, p := range c.Profiles { + if p.Name == name { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("profile %q not found", name) + } + c.Profiles = append(c.Profiles[:idx], c.Profiles[idx+1:]...) + if c.Active == name { + c.Active = c.Profiles[0].Name + } + return nil +} + +func defaultListen() []string { + return []string{ + "socks://127.0.0.1:1080", + "http://127.0.0.1:1081", + } +} diff --git a/internal/core/engine.go b/internal/core/engine.go new file mode 100644 index 0000000..39239dd --- /dev/null +++ b/internal/core/engine.go @@ -0,0 +1,28 @@ +package core + +import ( + "context" + + "vpnclient/internal/config" +) + +// Engine runs one protocol backend (e.g. official naive.exe). +type Engine interface { + Protocol() config.Protocol + Start(ctx context.Context, profile config.Profile, binDir string) error + Stop() error + Running() bool + LocalHTTPProxy() (hostPort string, ok bool) + LocalSOCKSProxy() (hostPort string, ok bool) +} + +// Status is a snapshot of the connection manager. +type Status struct { + Connected bool `json:"connected"` + Profile string `json:"profile,omitempty"` + Protocol config.Protocol `json:"protocol,omitempty"` + HTTPProxy string `json:"http_proxy,omitempty"` + SOCKSProxy string `json:"socks_proxy,omitempty"` + SystemProxy bool `json:"system_proxy"` + Extra map[string]string `json:"extra,omitempty"` +} diff --git a/internal/core/manager.go b/internal/core/manager.go new file mode 100644 index 0000000..f729cbe --- /dev/null +++ b/internal/core/manager.go @@ -0,0 +1,338 @@ +package core + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "vpnclient/internal/config" + "vpnclient/internal/protocols/naive" + "vpnclient/internal/sysproxy" +) + +// Manager orchestrates protocol engines and Windows system proxy. +type Manager struct { + mu sync.Mutex + cfgPath string + cfg *config.Config + engine Engine + sys sysproxy.Controller + profile *config.Profile + stderr io.Writer + binDir string +} + +func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) { + if stderr == nil { + stderr = os.Stderr + } + binDir, err := config.ResolveBinDir(cfgPath, cfg.BinDir) + if err != nil { + return nil, err + } + return &Manager{ + cfgPath: cfgPath, + cfg: cfg, + sys: sysproxy.New(), + stderr: stderr, + binDir: binDir, + }, nil +} + +func (m *Manager) Connect(ctx context.Context, profileName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("already connected; disconnect first") + } + + if profileName != "" { + m.cfg.Active = profileName + } + profile, err := m.cfg.ActiveProfile() + if err != nil { + return err + } + + engine, err := m.newEngine(profile.Protocol) + if err != nil { + return err + } + + if err := engine.Start(ctx, *profile, m.binDir); err != nil { + return err + } + + if m.cfg.SystemProxy { + httpProxy, ok := engine.LocalHTTPProxy() + if !ok { + _ = engine.Stop() + 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) + } + } + + m.engine = engine + m.profile = profile + return nil +} + +func (m *Manager) Disconnect() error { + m.mu.Lock() + defer m.mu.Unlock() + + var first error + if m.sys.Enabled() { + if err := m.sys.Disable(); err != nil && first == nil { + first = err + } + } + if m.engine != nil { + if err := m.engine.Stop(); err != nil && first == nil { + first = err + } + m.engine = nil + } + m.profile = nil + return first +} + +func (m *Manager) Status() Status { + m.mu.Lock() + defer m.mu.Unlock() + + st := Status{SystemProxy: m.sys.Enabled()} + if m.engine == nil || !m.engine.Running() { + return st + } + st.Connected = true + if m.profile != nil { + st.Profile = m.profile.Name + st.Protocol = m.profile.Protocol + } + if hp, ok := m.engine.LocalHTTPProxy(); ok { + st.HTTPProxy = hp + } + if sp, ok := m.engine.LocalSOCKSProxy(); ok { + st.SOCKSProxy = sp + } + return st +} + +// Probe fetches url via the local HTTP or SOCKS proxy to verify the tunnel. +func (m *Manager) Probe(ctx context.Context, testURL string) error { + m.mu.Lock() + engine := m.engine + m.mu.Unlock() + if engine == nil || !engine.Running() { + return fmt.Errorf("not connected") + } + if testURL == "" { + testURL = "https://www.google.com/generate_204" + } + + var proxyURL *url.URL + if hp, ok := engine.LocalHTTPProxy(); ok { + proxyURL, _ = url.Parse("http://" + hp) + } else if sp, ok := engine.LocalSOCKSProxy(); ok { + proxyURL, _ = url.Parse("socks5://" + sp) + } else { + return fmt.Errorf("no local proxy listen address") + } + + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + DialContext: (&net.Dialer{ + Timeout: 15 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 15 * time.Second, + ForceAttemptHTTP2: true, + } + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("probe via %s: %w", proxyURL.String(), err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("probe unexpected status %d", resp.StatusCode) + } + return nil +} + +func (m *Manager) BinDir() string { return m.binDir } + +func (m *Manager) ConfigPath() string { return m.cfgPath } + +func (m *Manager) Config() *config.Config { + m.mu.Lock() + defer m.mu.Unlock() + return m.cfg +} + +func (m *Manager) SetSystemProxy(enabled bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.cfg.SystemProxy = enabled +} + +func (m *Manager) UpdateProxyURI(proxy string) error { + m.mu.Lock() + defer m.mu.Unlock() + normalized, err := naive.NormalizeProxyURI(proxy) + if err != nil { + return err + } + if err := m.cfg.SetActiveProxy(normalized); err != nil { + return err + } + return config.Save(m.cfgPath, *m.cfg) +} + +func (m *Manager) SetActiveProfile(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("сначала отключитесь") + } + if err := m.cfg.SetActive(name); err != nil { + return err + } + return config.Save(m.cfgPath, *m.cfg) +} + +func (m *Manager) SaveProfile(name, proxy string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("сначала отключитесь") + } + proxy = strings.TrimSpace(proxy) + if proxy != "" { + normalized, err := naive.NormalizeProxyURI(proxy) + if err != nil { + return err + } + proxy = normalized + } + if err := m.cfg.UpsertProfile(name, proxy); err != nil { + return err + } + return config.Save(m.cfgPath, *m.cfg) +} + +// SaveActiveProfile updates the current profile (rename if needed) and proxy URI. +func (m *Manager) SaveActiveProfile(name, proxy string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("сначала отключитесь") + } + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("имя профиля пустое") + } + proxy = strings.TrimSpace(proxy) + if proxy != "" { + normalized, err := naive.NormalizeProxyURI(proxy) + if err != nil { + return err + } + proxy = normalized + } + + active := m.cfg.Active + if name != active { + exists := false + for _, p := range m.cfg.Profiles { + if p.Name == name { + exists = true + break + } + } + if exists { + if err := m.cfg.SetActive(name); err != nil { + return err + } + } else if err := m.cfg.RenameProfile(active, name); err != nil { + return err + } + } + if err := m.cfg.SetActiveProxy(proxy); err != nil { + return err + } + return config.Save(m.cfgPath, *m.cfg) +} + +func (m *Manager) DeleteProfile(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("сначала отключитесь") + } + if err := m.cfg.DeleteProfile(name); err != nil { + return err + } + return config.Save(m.cfgPath, *m.cfg) +} + +func (m *Manager) Profiles() []config.ProfileInfo { + m.mu.Lock() + defer m.mu.Unlock() + return m.cfg.ListProfiles() +} + +func (m *Manager) SaveConfig() error { + m.mu.Lock() + defer m.mu.Unlock() + return config.Save(m.cfgPath, *m.cfg) +} + +func (m *Manager) Reload() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.engine != nil && m.engine.Running() { + return fmt.Errorf("disconnect before reloading config") + } + cfg, err := config.Load(m.cfgPath) + if err != nil { + return err + } + binDir, err := config.ResolveBinDir(m.cfgPath, cfg.BinDir) + if err != nil { + return err + } + m.cfg = cfg + m.binDir = binDir + return nil +} + +func (m *Manager) newEngine(p config.Protocol) (Engine, error) { + switch p { + case config.ProtocolNaive: + return naive.New(m.stderr), nil + default: + return nil, fmt.Errorf("unsupported protocol %q", p) + } +} diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go new file mode 100644 index 0000000..4421428 --- /dev/null +++ b/internal/netcheck/ping.go @@ -0,0 +1,69 @@ +package netcheck + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + "time" + + "vpnclient/internal/protocols/naive" +) + +// Result is a TCP reachability measurement to a proxy host. +type Result struct { + Name string `json:"name"` + Host string `json:"host"` + Port string `json:"port"` + Ms int64 `json:"ms"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// PingProxyURI measures TCP connect time to the host in a naive share/proxy URI. +func PingProxyURI(ctx context.Context, name, proxyURI string) Result { + res := Result{Name: name} + if strings.TrimSpace(proxyURI) == "" { + res.Error = "нет ссылки сервера" + return res + } + normalized, err := naive.NormalizeProxyURI(proxyURI) + if err != nil { + res.Error = err.Error() + return res + } + u, err := url.Parse(normalized) + if err != nil { + res.Error = err.Error() + return res + } + host := u.Hostname() + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "https", "quic": + port = "443" + default: + port = "80" + } + } + res.Host = host + res.Port = port + if host == "" { + res.Error = "нет хоста" + return res + } + + d := net.Dialer{Timeout: 4 * time.Second} + start := time.Now() + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil { + res.Error = fmt.Sprintf("недоступен: %v", err) + return res + } + _ = conn.Close() + res.Ms = time.Since(start).Milliseconds() + res.OK = true + return res +} diff --git a/internal/protocols/naive/binary.go b/internal/protocols/naive/binary.go new file mode 100644 index 0000000..bec973e --- /dev/null +++ b/internal/protocols/naive/binary.go @@ -0,0 +1,192 @@ +package naive + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest" + +// ResolveBinary finds naive.exe/naive in binDir or PATH. +func ResolveBinary(binDir string) (string, error) { + name := "naive" + if runtime.GOOS == "windows" { + name = "naive.exe" + } + candidates := []string{ + filepath.Join(binDir, name), + filepath.Join(binDir, "naiveproxy", name), + } + if exe, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(exe), name)) + candidates = append(candidates, filepath.Join(filepath.Dir(exe), "bin", name)) + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c, nil + } + } + return "", fmt.Errorf("naive binary not found (looked in %s); run: vpnclient install-core", binDir) +} + +// EnsureBinary downloads the official Windows/Linux/macOS release if missing. +func EnsureBinary(binDir string) (string, error) { + if path, err := ResolveBinary(binDir); err == nil { + return path, nil + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + return "", err + } + pattern, err := assetNameForPlatform() + if err != nil { + return "", err + } + fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern) + assetName, url, err := findReleaseAsset(pattern) + if err != nil { + return "", err + } + zipPath := filepath.Join(binDir, assetName) + if err := downloadFile(zipPath, url); err != nil { + return "", err + } + defer os.Remove(zipPath) + + if err := unzipNaive(zipPath, binDir); err != nil { + return "", err + } + return ResolveBinary(binDir) +} + +func assetNameForPlatform() (string, error) { + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + return "naiveproxy-*-win-x64.zip", nil + case runtime.GOOS == "windows" && runtime.GOARCH == "arm64": + return "naiveproxy-*-win-arm64.zip", nil + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + return "naiveproxy-*-linux-x64.zip", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + return "naiveproxy-*-mac-x64.tar.xz", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + return "naiveproxy-*-mac-arm64.tar.xz", 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) + 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 + } + prefix := strings.Split(pattern, "*")[0] + suffix := "" + if parts := strings.Split(pattern, "*"); len(parts) == 2 { + suffix = parts[1] + } + 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 + } + } + 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, resp.Body) + return err +} + +func unzipNaive(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + want := "naive" + if runtime.GOOS == "windows" { + want = "naive.exe" + } + var found bool + for _, f := range r.File { + base := filepath.Base(f.Name) + if base != want { + continue + } + if err := extractFile(f, filepath.Join(destDir, want)); err != nil { + return err + } + found = true + break + } + if !found { + return fmt.Errorf("zip does not contain %s", want) + } + return nil +} + +func extractFile(f *zip.File, dest string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, rc) + return err +} diff --git a/internal/protocols/naive/engine.go b/internal/protocols/naive/engine.go new file mode 100644 index 0000000..b993023 --- /dev/null +++ b/internal/protocols/naive/engine.go @@ -0,0 +1,221 @@ +package naive + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "vpnclient/internal/config" +) + +// Engine runs the official Chromium-based naive binary. +// NaiveProxy cannot be reimplemented in pure Go: TLS/H2 fingerprints +// must match Chrome, which requires the upstream naive.exe. +type Engine struct { + mu sync.Mutex + cmd *exec.Cmd + cancel context.CancelFunc + done chan struct{} + workdir string + profile config.Profile + stderr io.Writer + running bool +} + +func New(stderr io.Writer) *Engine { + if stderr == nil { + stderr = os.Stderr + } + return &Engine{stderr: stderr} +} + +func (e *Engine) Protocol() config.Protocol { return config.ProtocolNaive } + +func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir string) error { + e.mu.Lock() + defer e.mu.Unlock() + + if e.running { + return fmt.Errorf("naive: already running") + } + + bin, err := ResolveBinary(binDir) + if err != nil { + return err + } + + workdir, err := os.MkdirTemp("", "vpnclient-naive-*") + if err != nil { + return fmt.Errorf("naive: temp dir: %w", err) + } + + cfgPath := filepath.Join(workdir, "config.json") + if err := writeRuntimeConfig(cfgPath, profile); err != nil { + os.RemoveAll(workdir) + return err + } + + runCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(runCtx, bin, cfgPath) + cmd.Dir = workdir + cmd.Stdout = e.stderr + cmd.Stderr = e.stderr + applySysProcAttr(cmd) + env := os.Environ() + for k, v := range profile.Env { + env = append(env, k+"="+v) + } + cmd.Env = env + + if err := cmd.Start(); err != nil { + cancel() + os.RemoveAll(workdir) + return fmt.Errorf("naive: start %s: %w", bin, err) + } + + done := make(chan struct{}) + e.cmd = cmd + e.cancel = cancel + e.done = done + e.workdir = workdir + e.profile = profile + e.running = true + + go func() { + err := cmd.Wait() + if err != nil { + fmt.Fprintf(e.stderr, "naive: process ended: %v\n", err) + } + e.mu.Lock() + e.cleanupLocked() + e.mu.Unlock() + close(done) + }() + + // Release lock while waiting so the Wait goroutine can clean up. + e.mu.Unlock() + timer := time.NewTimer(800 * time.Millisecond) + defer timer.Stop() + var startErr error + select { + case <-done: + startErr = fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary") + case <-ctx.Done(): + _ = e.Stop() + startErr = ctx.Err() + case <-timer.C: + e.mu.Lock() + alive := e.running + e.mu.Unlock() + if !alive { + startErr = fmt.Errorf("naive: process exited during startup") + } + } + e.mu.Lock() + return startErr +} + +func (e *Engine) Stop() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.stopLocked() +} + +func (e *Engine) stopLocked() error { + if !e.running || e.cmd == nil { + return nil + } + done := e.done + if e.cancel != nil { + e.cancel() + } + proc := e.cmd.Process + e.mu.Unlock() + select { + case <-done: + case <-time.After(2 * time.Second): + if proc != nil { + _ = proc.Kill() + } + <-done + } + e.mu.Lock() + return nil +} + +func (e *Engine) cleanupLocked() { + if e.workdir != "" { + _ = os.RemoveAll(e.workdir) + e.workdir = "" + } + e.cmd = nil + e.cancel = nil + e.running = false +} + +func (e *Engine) Running() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +func (e *Engine) LocalHTTPProxy() (string, bool) { + e.mu.Lock() + defer e.mu.Unlock() + if !e.running { + return "", false + } + return e.profile.HTTPListenHostPort() +} + +func (e *Engine) LocalSOCKSProxy() (string, bool) { + e.mu.Lock() + defer e.mu.Unlock() + if !e.running { + return "", false + } + return e.profile.SOCKSListenHostPort() +} + +type runtimeConfig struct { + Listen any `json:"listen"` + Proxy string `json:"proxy"` + InsecureConcurrency int `json:"insecure-concurrency,omitempty"` + ExtraHeaders string `json:"extra-headers,omitempty"` + HostResolverRules string `json:"host-resolver-rules,omitempty"` + Log string `json:"log,omitempty"` + NoPostQuantum bool `json:"no-post-quantum,omitempty"` +} + +func writeRuntimeConfig(path string, profile config.Profile) error { + listen := profile.DefaultListen() + var listenField any = listen + if len(listen) == 1 { + listenField = listen[0] + } + proxy, err := NormalizeProxyURI(profile.Proxy) + if err != nil { + return err + } + rc := runtimeConfig{ + Listen: listenField, + Proxy: proxy, + InsecureConcurrency: profile.InsecureConcurrency, + ExtraHeaders: profile.ExtraHeaders, + HostResolverRules: profile.HostResolverRules, + Log: profile.Log, + NoPostQuantum: profile.NoPostQuantum, + } + data, err := json.MarshalIndent(rc, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/protocols/naive/sys_other.go b/internal/protocols/naive/sys_other.go new file mode 100644 index 0000000..347d286 --- /dev/null +++ b/internal/protocols/naive/sys_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package naive + +import "os/exec" + +func applySysProcAttr(cmd *exec.Cmd) {} diff --git a/internal/protocols/naive/sys_windows.go b/internal/protocols/naive/sys_windows.go new file mode 100644 index 0000000..69ece8b --- /dev/null +++ b/internal/protocols/naive/sys_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package naive + +import ( + "os/exec" + "syscall" +) + +func applySysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/protocols/naive/uri.go b/internal/protocols/naive/uri.go new file mode 100644 index 0000000..427c325 --- /dev/null +++ b/internal/protocols/naive/uri.go @@ -0,0 +1,68 @@ +package naive + +import ( + "fmt" + "net/url" + "strings" +) + +// NormalizeProxyURI converts share links and aliases into a naive --proxy URI. +// +// Supported inputs: +// - https://user:pass@host:443 +// - quic://user:pass@host +// - naive+https://user:pass@host:443#remark +// - naive+quic://user:pass@host#remark +func NormalizeProxyURI(raw string) (string, error) { + uri, _, err := ParseShareLink(raw) + return uri, err +} + +// ParseShareLink returns a normalized proxy URI and optional remark from #fragment. +func ParseShareLink(raw string) (proxyURI, remark string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("empty proxy URI") + } + + if i := strings.IndexByte(raw, '#'); i >= 0 { + remark = strings.TrimSpace(raw[i+1:]) + raw = raw[:i] + } + + lower := strings.ToLower(raw) + switch { + case strings.HasPrefix(lower, "naive+https://"): + raw = "https://" + raw[len("naive+https://"):] + case strings.HasPrefix(lower, "naive+quic://"): + raw = "quic://" + raw[len("naive+quic://"):] + case strings.HasPrefix(lower, "naive://"): + raw = "https://" + raw[len("naive://"):] + } + + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("parse proxy URI: %w", err) + } + switch strings.ToLower(u.Scheme) { + case "https", "quic", "http": + default: + return "", "", fmt.Errorf("unsupported proxy scheme %q (use https://, quic:// or naive+https://)", u.Scheme) + } + if u.Host == "" { + return "", "", fmt.Errorf("proxy URI missing host") + } + if u.User == nil || u.User.Username() == "" { + return "", "", fmt.Errorf("proxy URI missing username") + } + if _, ok := u.User.Password(); !ok { + return "", "", fmt.Errorf("proxy URI missing password") + } + + out := &url.URL{ + Scheme: strings.ToLower(u.Scheme), + User: u.User, + Host: u.Host, + } + return out.String(), remark, nil +} diff --git a/internal/protocols/naive/uri_test.go b/internal/protocols/naive/uri_test.go new file mode 100644 index 0000000..b3e3ef9 --- /dev/null +++ b/internal/protocols/naive/uri_test.go @@ -0,0 +1,23 @@ +package naive + +import "testing" + +func TestNormalizeProxyURI(t *testing.T) { + in := "naive+https://user:pass@de50.example.com:443#site_u1_s454" + got, err := NormalizeProxyURI(in) + if err != nil { + t.Fatal(err) + } + want := "https://user:pass@de50.example.com:443" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + + got, err = NormalizeProxyURI("quic://user:pass@host") + if err != nil { + t.Fatal(err) + } + if got != "quic://user:pass@host" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/sysproxy/other.go b/internal/sysproxy/other.go new file mode 100644 index 0000000..2dc6b1d --- /dev/null +++ b/internal/sysproxy/other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package sysproxy + +type stubController struct{} + +func newPlatform() Controller { return stubController{} } + +func (stubController) Enable(string) error { return ErrUnsupported } +func (stubController) Disable() error { return nil } +func (stubController) Enabled() bool { return false } diff --git a/internal/sysproxy/sysproxy.go b/internal/sysproxy/sysproxy.go new file mode 100644 index 0000000..11b70ee --- /dev/null +++ b/internal/sysproxy/sysproxy.go @@ -0,0 +1,18 @@ +package sysproxy + +import "fmt" + +// Controller manages OS-level proxy settings. +type Controller interface { + Enable(httpHostPort string) error + Disable() error + Enabled() bool +} + +// New returns a platform-specific controller. +func New() Controller { + return newPlatform() +} + +// ErrUnsupported is returned on platforms without system proxy support. +var ErrUnsupported = fmt.Errorf("system proxy is not supported on this platform") diff --git a/internal/sysproxy/windows.go b/internal/sysproxy/windows.go new file mode 100644 index 0000000..694d87b --- /dev/null +++ b/internal/sysproxy/windows.go @@ -0,0 +1,154 @@ +//go:build windows + +package sysproxy + +import ( + "fmt" + "os/exec" + "strings" + "syscall" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + internetOptionSettingsChanged = 39 + internetOptionRefresh = 37 +) + +type windowsController struct { + enabled bool + hadProxy bool + oldEnable uint64 + oldServer string + oldOverride string +} + +func newPlatform() Controller { + return &windowsController{} +} + +func (c *windowsController) Enabled() bool { return c.enabled } + +func (c *windowsController) Enable(httpHostPort string) error { + if httpHostPort == "" { + return fmt.Errorf("sysproxy: empty http proxy address") + } + key, err := registry.OpenKey(registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return fmt.Errorf("sysproxy: open registry: %w", err) + } + defer key.Close() + + if !c.enabled { + c.oldEnable, _, _ = key.GetIntegerValue("ProxyEnable") + c.oldServer, _, _ = key.GetStringValue("ProxyServer") + c.oldOverride, _, _ = key.GetStringValue("ProxyOverride") + c.hadProxy = true + } + + override := strings.Join([]string{ + "localhost", + "127.*", + "10.*", + "172.16.*", + "172.17.*", + "172.18.*", + "172.19.*", + "172.20.*", + "172.21.*", + "172.22.*", + "172.23.*", + "172.24.*", + "172.25.*", + "172.26.*", + "172.27.*", + "172.28.*", + "172.29.*", + "172.30.*", + "172.31.*", + "192.168.*", + "", + }, ";") + + if err := key.SetDWordValue("ProxyEnable", 1); err != nil { + return err + } + if err := key.SetStringValue("ProxyServer", httpHostPort); err != nil { + return err + } + if err := key.SetStringValue("ProxyOverride", override); err != nil { + return err + } + + if err := notifyInternetSettingsChanged(); err != nil { + return err + } + _ = setWinHTTPProxy(httpHostPort) + + c.enabled = true + return nil +} + +func (c *windowsController) Disable() error { + if !c.enabled { + return nil + } + key, err := registry.OpenKey(registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return err + } + defer key.Close() + + if c.hadProxy { + _ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable)) + if c.oldServer != "" { + _ = key.SetStringValue("ProxyServer", c.oldServer) + } else { + _ = key.DeleteValue("ProxyServer") + } + if c.oldOverride != "" { + _ = key.SetStringValue("ProxyOverride", c.oldOverride) + } + } else { + _ = key.SetDWordValue("ProxyEnable", 0) + } + + _ = notifyInternetSettingsChanged() + _ = resetWinHTTPProxy() + + c.enabled = false + return nil +} + +func notifyInternetSettingsChanged() error { + wininet := windows.NewLazySystemDLL("wininet.dll") + proc := wininet.NewProc("InternetSetOptionW") + r1, _, err := proc.Call(0, uintptr(internetOptionSettingsChanged), 0, 0) + if r1 == 0 { + return fmt.Errorf("InternetSetOption(SETTINGS_CHANGED): %w", err) + } + r1, _, err = proc.Call(0, uintptr(internetOptionRefresh), 0, 0) + if r1 == 0 { + return fmt.Errorf("InternetSetOption(REFRESH): %w", err) + } + return nil +} + +func setWinHTTPProxy(httpHostPort string) error { + cmd := exec.Command("netsh", "winhttp", "set", "proxy", httpHostPort, + "bypass-list=localhost;127.*;10.*;192.168.*;") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd.Run() +} + +func resetWinHTTPProxy() error { + cmd := exec.Command("netsh", "winhttp", "reset", "proxy") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd.Run() +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..74ffd29 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,266 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// CurrentVersion is the shipped client version. +const CurrentVersion = "1.1.2" + +// 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"` +} + +// Status is returned to the UI. +type Status struct { + Current string `json:"current"` + Latest string `json:"latest,omitempty"` + Notes string `json:"notes,omitempty"` + URL string `json:"url,omitempty"` + Available bool `json:"available"` + Mandatory bool `json:"mandatory"` + Checking bool `json:"checking,omitempty"` + Error string `json:"error,omitempty"` + ManifestURL string `json:"manifest_url"` +} + +// Check fetches the manifest and compares versions. +func Check(ctx context.Context, manifestURL string) (Status, error) { + if manifestURL == "" { + manifestURL = DefaultManifestURL + } + st := Status{Current: CurrentVersion, ManifestURL: manifestURL} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + 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 != "" + return st, nil +} + +// Compare returns 1 if a>b, -1 if a n { + n = len(bs) + } + for i := 0; i < n; i++ { + var ai, bi int + if i < len(as) { + ai = as[i] + } + if i < len(bs) { + bi = bs[i] + } + if ai > bi { + return 1 + } + if ai < bi { + return -1 + } + } + return 0 +} + +func splitVer(v string) []int { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + parts := strings.Split(v, ".") + out := make([]int, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + out = append(out, 0) + continue + } + // strip pre-release suffix: 1.2.3-beta + if i := strings.IndexAny(p, "-+"); i >= 0 { + p = p[:i] + } + n, _ := strconv.Atoi(p) + out = append(out, n) + } + 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/") || + strings.HasPrefix(u, "https://files.de4ima.uk/") +} + +func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) { + if manifestURL == "" { + manifestURL = DefaultManifestURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + if err != nil { + return Manifest{}, err + } + req.Header.Set("User-Agent", "Navis/"+CurrentVersion) + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Manifest{}, err + } + defer resp.Body.Close() + var m Manifest + err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m) + return m, err +} + +func downloadFile(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Navis/"+CurrentVersion) + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download HTTP %d", resp.StatusCode) + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, io.LimitReader(resp.Body, 200<<20)) + return err +} + +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, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..d75107d --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,15 @@ +package update + +import "testing" + +func TestCompare(t *testing.T) { + if Compare("1.1.0", "1.0.0") <= 0 { + t.Fatal("expected newer") + } + if Compare("1.0.0", "1.1.0") >= 0 { + t.Fatal("expected older") + } + if Compare("1.1.0", "1.1.0") != 0 { + t.Fatal("expected equal") + } +} diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..658c135 --- /dev/null +++ b/server/README.md @@ -0,0 +1,18 @@ +# Хостинг обновлений Navis + +Обновления отдаются из 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` + +Пример `dist/update.json`: + +```json +{ + "version": "1.1.2", + "notes": "Что нового", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "hex", + "mandatory": false +} +``` diff --git a/server/update.json b/server/update.json new file mode 100644 index 0000000..075a1eb --- /dev/null +++ b/server/update.json @@ -0,0 +1,7 @@ +{ + "version": "1.1.2", + "notes": "Обновления через git.evilfox.cc", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85", + "mandatory": false +} \ No newline at end of file diff --git a/tools/mkico/main.go b/tools/mkico/main.go new file mode 100644 index 0000000..e8c8b25 --- /dev/null +++ b/tools/mkico/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/binary" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + + "golang.org/x/image/draw" +) + +func main() { + in := filepath.Join("assets", "navis-icon.png") + out := filepath.Join("assets", "navis.ico") + f, err := os.Open(in) + if err != nil { + fatal(err) + } + defer f.Close() + src, err := png.Decode(f) + if err != nil { + fatal(err) + } + + sizes := []int{16, 32, 48, 64, 128, 256} + type entry struct { + size int + png []byte + } + var entries []entry + for _, size := range sizes { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + tmp := filepath.Join(os.TempDir(), fmt.Sprintf("navis-%d.png", size)) + tf, err := os.Create(tmp) + if err != nil { + fatal(err) + } + if err := png.Encode(tf, dst); err != nil { + tf.Close() + fatal(err) + } + tf.Close() + b, err := os.ReadFile(tmp) + _ = os.Remove(tmp) + if err != nil { + fatal(err) + } + entries = append(entries, entry{size: size, png: b}) + } + + var blob []byte + offset := 6 + 16*len(entries) + for _, e := range entries { + blob = append(blob, e.png...) + } + + buf := make([]byte, offset+len(blob)) + binary.LittleEndian.PutUint16(buf[0:], 0) // reserved + binary.LittleEndian.PutUint16(buf[2:], 1) // icon + binary.LittleEndian.PutUint16(buf[4:], uint16(len(entries))) + dataOff := offset + for i, e := range entries { + dir := buf[6+i*16:] + w, h := e.size, e.size + if w >= 256 { + dir[0], dir[1] = 0, 0 + } else { + dir[0], dir[1] = byte(w), byte(h) + } + dir[2] = 0 // colors + dir[3] = 0 + binary.LittleEndian.PutUint16(dir[4:], 1) // planes + binary.LittleEndian.PutUint16(dir[6:], 32) + binary.LittleEndian.PutUint32(dir[8:], uint32(len(e.png))) + binary.LittleEndian.PutUint32(dir[12:], uint32(dataOff)) + copy(buf[dataOff:], e.png) + dataOff += len(e.png) + } + if err := os.WriteFile(out, buf, 0o644); err != nil { + fatal(err) + } + fmt.Println("wrote", out) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/versioninfo.json b/versioninfo.json new file mode 100644 index 0000000..368d833 --- /dev/null +++ b/versioninfo.json @@ -0,0 +1,27 @@ +{ + "FixedFileInfo": { + "FileVersion": { "Major": 1, "Minor": 1, "Patch": 2, "Build": 0 }, + "ProductVersion": { "Major": 1, "Minor": 1, "Patch": 2, "Build": 0 }, + "FileFlagsMask": "3f", + "FileFlags": "00", + "FileOS": "40004", + "FileType": "01", + "FileSubType": "00" + }, + "StringFileInfo": { + "CompanyName": "Navis", + "FileDescription": "Navis — NaiveProxy client", + "FileVersion": "1.1.2.0", + "InternalName": "Navis", + "OriginalFilename": "Navis.exe", + "ProductName": "Navis", + "ProductVersion": "1.1.2.0" + }, + "VarFileInfo": { + "Translation": { + "LangID": "0409", + "CharsetID": "04B0" + } + }, + "IconPath": "assets/navis.ico" +}