diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index e6505cd..bee9592 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -23,6 +23,7 @@ import ( "vpnclient/internal/config" "vpnclient/internal/core" "vpnclient/internal/netcheck" + "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" "vpnclient/internal/update" ) @@ -93,10 +94,7 @@ func main() { _ = os.MkdirAll(dataPath, 0o755) go func() { - if _, err := naive.ResolveBinary(mgr.BinDir()); err == nil { - return - } - _, _ = naive.EnsureBinary(mgr.BinDir()) + _, _ = mgr.EnsureAllCores() }() w := webview2.NewWithOptions(webview2.WebViewOptions{ @@ -160,7 +158,22 @@ func (a *app) getState() (uiState, error) { proxy = p.Proxy active = p.Name } - corePath, coreErr := naive.ResolveBinary(a.mgr.BinDir()) + corePath := "" + coreReady := false + if p, err := cfg.ActiveProfile(); err == nil { + switch p.Protocol { + case config.ProtocolHysteria2: + if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil { + corePath, coreReady = path, true + } + default: + if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil { + corePath, coreReady = path, true + } + } + } else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil { + corePath, coreReady = path, true + } out := uiState{ Connected: st.Connected, Profile: st.Profile, @@ -170,7 +183,7 @@ func (a *app) getState() (uiState, error) { SOCKSProxy: st.SOCKSProxy, SystemProxy: cfg.SystemProxy, Proxy: proxy, - CoreReady: coreErr == nil, + CoreReady: coreReady, CorePath: corePath, ConfigPath: a.cfgPath, Profiles: cfg.ListProfiles(), @@ -178,6 +191,11 @@ func (a *app) getState() (uiState, error) { Update: a.updateStatus, Pings: append([]netcheck.Result(nil), a.pings...), } + if out.Protocol == "" { + if p, err := cfg.ActiveProfile(); err == nil { + out.Protocol = string(p.Protocol) + } + } if st.Connected { out.SystemProxy = st.SystemProxy } @@ -228,7 +246,7 @@ func (a *app) connect() error { return fmt.Errorf("сначала вставьте ссылку сервера") } - if _, err := naive.EnsureBinary(a.mgr.BinDir()); err != nil { + if _, err := a.mgr.EnsureCore(""); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) @@ -245,7 +263,15 @@ func (a *app) disconnect() error { func (a *app) installCore() (string, error) { a.mu.Lock() defer a.mu.Unlock() - return naive.EnsureBinary(a.mgr.BinDir()) + paths, err := a.mgr.EnsureAllCores() + if err != nil { + return "", err + } + parts := make([]string, 0, len(paths)) + for k, v := range paths { + parts = append(parts, k+": "+v) + } + return strings.Join(parts, " | "), nil } func (a *app) pingServers() ([]netcheck.Result, error) { @@ -263,7 +289,7 @@ func (a *app) pingServers() ([]netcheck.Result, error) { out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"}) continue } - out = append(out, netcheck.PingProxyURI(ctx, p.Name, proxy)) + out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy)) } a.mu.Lock() diff --git a/cmd/vpnclient/main.go b/cmd/vpnclient/main.go index 2dc538a..0671fcb 100644 --- a/cmd/vpnclient/main.go +++ b/cmd/vpnclient/main.go @@ -13,6 +13,7 @@ import ( "vpnclient/internal/config" "vpnclient/internal/core" + "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" ) @@ -110,12 +111,18 @@ func runInstallCore(args []string) int { binDir = "bin" } } - path, err := naive.EnsureBinary(binDir) + pathNaive, err := naive.EnsureBinary(binDir) if err != nil { - fmt.Fprintf(os.Stderr, "install-core: %v\n", err) + fmt.Fprintf(os.Stderr, "install-core naive: %v\n", err) return 1 } - fmt.Printf("naive core ready: %s\n", path) + fmt.Printf("naive core ready: %s\n", pathNaive) + pathHy2, err := hysteria2.EnsureBinary(binDir) + if err != nil { + fmt.Fprintf(os.Stderr, "install-core hysteria2: %v\n", err) + return 1 + } + fmt.Printf("hysteria2 core ready: %s\n", pathHy2) return 0 } @@ -149,7 +156,7 @@ func runConnect(args []string) int { } // Ensure official core is present. - if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil { + if _, err := mgr.EnsureCore(""); err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) return 1 } @@ -203,7 +210,7 @@ func runProbe(args []string) int { } cfg.SystemProxy = false - if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil { + if _, err := mgr.EnsureCore(""); err != nil { fmt.Fprintf(os.Stderr, "probe: %v\n", err) return 1 } diff --git a/dist/navis-1.2.0-windows-amd64.zip b/dist/navis-1.2.0-windows-amd64.zip new file mode 100644 index 0000000..c7a6d58 Binary files /dev/null and b/dist/navis-1.2.0-windows-amd64.zip differ diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 3b71d62..0e59f3f 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 0978b03..32845ae 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,7 +1,10 @@ { - "version": "1.1.3", - "notes": "Тестовый релиз автообновления 1.1.3", + "version": "1.2.0", + "notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", + "platform": "windows-amd64", + "os": "windows", + "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb", + "sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "mandatory": false } \ No newline at end of file diff --git a/dist/update.json b/dist/update.json index 0978b03..32845ae 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,7 +1,10 @@ { - "version": "1.1.3", - "notes": "Тестовый релиз автообновления 1.1.3", + "version": "1.2.0", + "notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", + "platform": "windows-amd64", + "os": "windows", + "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb", + "sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "mandatory": false } \ No newline at end of file diff --git a/internal/appui/index.html b/internal/appui/index.html index f330bd9..c9fc075 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -274,7 +274,7 @@

Navis

-

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

+

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

@@ -297,7 +297,7 @@
- +
@@ -316,7 +316,7 @@ - +

Загрузка…

@@ -340,7 +340,7 @@
- +
@@ -400,7 +400,7 @@ profiles.forEach((p) => { const opt = document.createElement("option"); opt.value = p.name; - opt.textContent = p.host ? (p.name + " · " + p.host) : p.name; + opt.textContent = (p.protocol ? ("[" + p.protocol + "] ") : "") + (p.host ? (p.name + " · " + p.host) : p.name); profile.appendChild(opt); }); const want = active || cur || (profiles[0] && profiles[0].name); @@ -482,9 +482,9 @@ if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy); detail = parts.join(" · "); } else if (state.core_ready === false) { - detail = "Сначала установите official naive.exe"; + detail = "Сначала установите cores (naive / hysteria2)"; } else { - detail = "Выберите профиль или создайте новый"; + detail = "Вставьте ссылку naive или hysteria2://"; } if (!busy) setMeta(detail, state.core_ready === false ? "err" : ""); } @@ -542,9 +542,9 @@ coreBtn.addEventListener("click", () => withBusy(async () => { try { - setMeta("Скачивание official naiveproxy…"); + setMeta("Скачивание official cores…"); const path = await installCore(); - setMeta("Core установлен: " + path, "ok"); + setMeta("Cores: " + path, "ok"); } catch (e) { setMeta(String(e), "err"); } })); diff --git a/internal/config/config.go b/internal/config/config.go index 7f3b5f7..97bf0a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,8 @@ import ( type Protocol string const ( - ProtocolNaive Protocol = "naive" + ProtocolNaive Protocol = "naive" + ProtocolHysteria2 Protocol = "hysteria2" ) // Config is the top-level client configuration. @@ -161,8 +162,10 @@ func (c *Config) Validate() error { return fmt.Errorf("config: profile[%d] missing name", i) } switch p.Protocol { - case ProtocolNaive: + case ProtocolNaive, ProtocolHysteria2: // Proxy may be empty until the user pastes a share link in the UI. + case "": + c.Profiles[i].Protocol = ProtocolNaive default: return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol) } diff --git a/internal/config/detect.go b/internal/config/detect.go new file mode 100644 index 0000000..780a19d --- /dev/null +++ b/internal/config/detect.go @@ -0,0 +1,18 @@ +package config + +import "strings" + +// DetectProtocol infers protocol from a share/proxy URI. +func DetectProtocol(raw string) Protocol { + lower := strings.ToLower(strings.TrimSpace(raw)) + switch { + case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"): + return ProtocolHysteria2 + case strings.HasPrefix(lower, "naive+"), strings.HasPrefix(lower, "naive://"), + strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"), + strings.HasPrefix(lower, "http://"): + return ProtocolNaive + default: + return "" + } +} diff --git a/internal/config/profiles.go b/internal/config/profiles.go index e4ccb9f..d473577 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -60,16 +60,29 @@ func (c *Config) SetActive(name string) error { // UpsertProfile creates or updates a profile by name. func (c *Config) UpsertProfile(name, proxy string) error { + return c.UpsertProfileWithProtocol(name, proxy, "") +} + +// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol. +func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("имя профиля пустое") } proxy = strings.TrimSpace(proxy) + if proto == "" { + proto = DetectProtocol(proxy) + } + if proto == "" { + proto = ProtocolNaive + } for i := range c.Profiles { if c.Profiles[i].Name == name { c.Profiles[i].Proxy = proxy - if c.Profiles[i].Protocol == "" { + if proto != "" { + c.Profiles[i].Protocol = proto + } else if c.Profiles[i].Protocol == "" { c.Profiles[i].Protocol = ProtocolNaive } if len(c.Profiles[i].Listen) == 0 { @@ -82,7 +95,7 @@ func (c *Config) UpsertProfile(name, proxy string) error { c.Profiles = append(c.Profiles, Profile{ Name: name, - Protocol: ProtocolNaive, + Protocol: proto, Proxy: proxy, Listen: defaultListen(), }) diff --git a/internal/core/manager.go b/internal/core/manager.go index f729cbe..d930f21 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -13,6 +13,8 @@ import ( "time" "vpnclient/internal/config" + "vpnclient/internal/linknorm" + "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" "vpnclient/internal/sysproxy" ) @@ -200,13 +202,20 @@ func (m *Manager) SetSystemProxy(enabled bool) { func (m *Manager) UpdateProxyURI(proxy string) error { m.mu.Lock() defer m.mu.Unlock() - normalized, err := naive.NormalizeProxyURI(proxy) + active, err := m.cfg.ActiveProfile() + if err != nil { + return err + } + normalized, proto, _, err := linknorm.Normalize(active.Protocol, proxy) if err != nil { return err } if err := m.cfg.SetActiveProxy(normalized); err != nil { return err } + if proto != "" { + _ = m.cfg.UpsertProfileWithProtocol(m.cfg.Active, normalized, proto) + } return config.Save(m.cfgPath, *m.cfg) } @@ -229,14 +238,16 @@ func (m *Manager) SaveProfile(name, proxy string) error { return fmt.Errorf("сначала отключитесь") } proxy = strings.TrimSpace(proxy) + var proto config.Protocol if proxy != "" { - normalized, err := naive.NormalizeProxyURI(proxy) + normalized, detected, _, err := linknorm.Normalize("", proxy) if err != nil { return err } proxy = normalized + proto = detected } - if err := m.cfg.UpsertProfile(name, proxy); err != nil { + if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { return err } return config.Save(m.cfgPath, *m.cfg) @@ -254,12 +265,18 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error { return fmt.Errorf("имя профиля пустое") } proxy = strings.TrimSpace(proxy) + var proto config.Protocol if proxy != "" { - normalized, err := naive.NormalizeProxyURI(proxy) + activeProto := config.Protocol("") + if p, err := m.cfg.ActiveProfile(); err == nil { + activeProto = p.Protocol + } + normalized, detected, _, err := linknorm.Normalize(activeProto, proxy) if err != nil { return err } proxy = normalized + proto = detected } active := m.cfg.Active @@ -279,7 +296,7 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error { return err } } - if err := m.cfg.SetActiveProxy(proxy); err != nil { + if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { return err } return config.Save(m.cfgPath, *m.cfg) @@ -330,9 +347,42 @@ func (m *Manager) Reload() error { func (m *Manager) newEngine(p config.Protocol) (Engine, error) { switch p { - case config.ProtocolNaive: + case config.ProtocolNaive, "": return naive.New(m.stderr), nil + case config.ProtocolHysteria2: + return hysteria2.New(m.stderr), nil default: return nil, fmt.Errorf("unsupported protocol %q", p) } } + +// EnsureCore downloads the binary required by the active (or given) protocol. +func (m *Manager) EnsureCore(proto config.Protocol) (string, error) { + if proto == "" { + if p, err := m.cfg.ActiveProfile(); err == nil { + proto = p.Protocol + } + } + switch proto { + case config.ProtocolHysteria2: + return hysteria2.EnsureBinary(m.binDir) + default: + return naive.EnsureBinary(m.binDir) + } +} + +// EnsureAllCores installs naive + hysteria2 cores. +func (m *Manager) EnsureAllCores() (map[string]string, error) { + out := map[string]string{} + p1, err := naive.EnsureBinary(m.binDir) + if err != nil { + return out, fmt.Errorf("naive: %w", err) + } + out["naive"] = p1 + p2, err := hysteria2.EnsureBinary(m.binDir) + if err != nil { + return out, fmt.Errorf("hysteria2: %w", err) + } + out["hysteria2"] = p2 + return out, nil +} diff --git a/internal/linknorm/normalize.go b/internal/linknorm/normalize.go new file mode 100644 index 0000000..555420e --- /dev/null +++ b/internal/linknorm/normalize.go @@ -0,0 +1,39 @@ +package linknorm + +import ( + "fmt" + "strings" + + "vpnclient/internal/config" + "vpnclient/internal/protocols/hysteria2" + "vpnclient/internal/protocols/naive" +) + +// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect). +func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", proto, "", fmt.Errorf("пустая ссылка") + } + if proto == "" { + proto = config.DetectProtocol(raw) + } + switch proto { + case config.ProtocolHysteria2: + u, rem, err := hysteria2.NormalizeShareLink(raw) + return u, config.ProtocolHysteria2, rem, err + case config.ProtocolNaive, "": + u, rem, err := naive.ParseShareLink(raw) + if err != nil { + // try hy2 if naive failed and looks like hy + if hysteria2.Detect(raw) { + u2, rem2, err2 := hysteria2.NormalizeShareLink(raw) + return u2, config.ProtocolHysteria2, rem2, err2 + } + return "", config.ProtocolNaive, "", err + } + return u, config.ProtocolNaive, rem, nil + default: + return "", proto, "", fmt.Errorf("unsupported protocol %q", proto) + } +} diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 4421428..3addb55 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -4,50 +4,70 @@ import ( "context" "fmt" "net" - "net/url" "strings" "time" - "vpnclient/internal/protocols/naive" + "vpnclient/internal/config" + "vpnclient/internal/linknorm" + "vpnclient/internal/protocols/hysteria2" ) // 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"` + 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. +// PingProxyURI measures TCP connect time to the host in a share/proxy URI. func PingProxyURI(ctx context.Context, name, proxyURI string) Result { + return PingProfile(ctx, name, "", proxyURI) +} + +// PingProfile pings using protocol hints when available. +func PingProfile(ctx context.Context, name string, proto config.Protocol, 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" + + var host, port string + if proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) { + h, p, err := hysteria2.HostPort(proxyURI) + if err != nil { + res.Error = err.Error() + return res + } + host, port = h, p + } else { + normalized, _, _, err := linknorm.Normalize(proto, proxyURI) + if err != nil { + res.Error = err.Error() + return res + } + // Parse host from normalized URI without importing net/url dance twice + rest := normalized + if i := strings.Index(rest, "://"); i >= 0 { + rest = rest[i+3:] + } + if at := strings.LastIndex(rest, "@"); at >= 0 { + rest = rest[at+1:] + } + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + rest = rest[:i] + } + host = rest + port = "443" + if i := strings.LastIndex(rest, ":"); i >= 0 { + host = rest[:i] + port = rest[i+1:] } } + res.Host = host res.Port = port if host == "" { diff --git a/internal/protocols/hysteria2/binary.go b/internal/protocols/hysteria2/binary.go new file mode 100644 index 0000000..dcf5d74 --- /dev/null +++ b/internal/protocols/hysteria2/binary.go @@ -0,0 +1,174 @@ +package hysteria2 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest" + +// ResolveBinary finds hysteria client binary. +func ResolveBinary(binDir string) (string, error) { + names := candidateNames() + candidates := []string{} + for _, name := range names { + candidates = append(candidates, + filepath.Join(binDir, name), + filepath.Join(binDir, "hysteria2", name), + filepath.Join(binDir, "hysteria", name), + ) + } + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + for _, name := range names { + candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name)) + } + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c, nil + } + } + return "", fmt.Errorf("hysteria2 binary not found in %s; run install-core", binDir) +} + +func candidateNames() []string { + if runtime.GOOS == "windows" { + return []string{ + "hysteria.exe", + "hysteria-windows-amd64.exe", + "hysteria-windows-amd64-avx.exe", + } + } + return []string{"hysteria", "hysteria-linux-amd64"} +} + +// EnsureBinary downloads official Hysteria 2 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 + } + want, err := releaseAssetName() + if err != nil { + return "", err + } + fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want) + name, url, err := findReleaseAsset(want) + if err != nil { + return "", err + } + destName := "hysteria" + if runtime.GOOS == "windows" { + destName = "hysteria.exe" + } + dest := filepath.Join(binDir, destName) + tmp := filepath.Join(binDir, name+".download") + if err := downloadFile(tmp, url); err != nil { + return "", err + } + _ = os.Remove(dest) + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + return "", err + } + _ = os.Chmod(dest, 0o755) + return ResolveBinary(binDir) +} + +func releaseAssetName() (string, error) { + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + return "hysteria-windows-amd64.exe", nil + case runtime.GOOS == "windows" && runtime.GOARCH == "arm64": + return "hysteria-windows-arm64.exe", nil + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + return "hysteria-linux-amd64", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + return "hysteria-darwin-amd64", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + return "hysteria-darwin-arm64", nil + default: + return "", fmt.Errorf("unsupported platform %s/%s for hysteria2 auto-download", 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(exactName string) (name, downloadURL string, err error) { + client := &http.Client{Timeout: 60 * time.Second} + req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "navis-vpnclient") + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("github api: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body)) + } + var rel ghRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return "", "", err + } + // Prefer exact non-avx name; fall back to avx variant on Windows. + var fallback string + var fallbackURL string + for _, a := range rel.Assets { + if a.Name == exactName { + return a.Name, a.BrowserDownloadURL, nil + } + if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" { + fallback, fallbackURL = a.Name, a.BrowserDownloadURL + } + } + if fallback != "" { + return fallback, fallbackURL, nil + } + // Prefix match for versioned names + for _, a := range rel.Assets { + if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) { + return a.Name, a.BrowserDownloadURL, nil + } + } + return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName) +} + +func downloadFile(path, url string) error { + client := &http.Client{Timeout: 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 +} diff --git a/internal/protocols/hysteria2/engine.go b/internal/protocols/hysteria2/engine.go new file mode 100644 index 0000000..904237c --- /dev/null +++ b/internal/protocols/hysteria2/engine.go @@ -0,0 +1,212 @@ +package hysteria2 + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "vpnclient/internal/config" +) + +// Engine runs the official apernet/hysteria client (Hysteria 2). +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.ProtocolHysteria2 } + +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("hysteria2: already running") + } + + bin, err := ResolveBinary(binDir) + if err != nil { + return err + } + + workdir, err := os.MkdirTemp("", "vpnclient-hy2-*") + if err != nil { + return fmt.Errorf("hysteria2: temp dir: %w", err) + } + + cfgPath := filepath.Join(workdir, "config.yaml") + if err := writeRuntimeConfig(cfgPath, profile); err != nil { + os.RemoveAll(workdir) + return err + } + + runCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(runCtx, bin, "-c", 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("hysteria2: 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, "hysteria2: process ended: %v\n", err) + } + e.mu.Lock() + e.cleanupLocked() + e.mu.Unlock() + close(done) + }() + + e.mu.Unlock() + timer := time.NewTimer(900 * time.Millisecond) + defer timer.Stop() + var startErr error + select { + case <-done: + startErr = fmt.Errorf("hysteria2: process exited immediately; check URI/password 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("hysteria2: 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() +} + +func writeRuntimeConfig(path string, profile config.Profile) error { + server, _, err := NormalizeShareLink(profile.Proxy) + if err != nil { + return err + } + socks := "127.0.0.1:1080" + httpListen := "127.0.0.1:1081" + if hp, ok := profile.SOCKSListenHostPort(); ok { + socks = hp + } + if hp, ok := profile.HTTPListenHostPort(); ok { + httpListen = hp + } + + var b strings.Builder + b.WriteString("# generated by Navis — do not edit\n") + b.WriteString("server: " + yamlQuote(server) + "\n") + b.WriteString("socks5:\n") + b.WriteString(" listen: " + yamlQuote(socks) + "\n") + b.WriteString("http:\n") + b.WriteString(" listen: " + yamlQuote(httpListen) + "\n") + return os.WriteFile(path, []byte(b.String()), 0o600) +} + +func yamlQuote(s string) string { + // Always quote — URIs and host:port can confuse YAML. + escaped := strings.ReplaceAll(s, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return `"` + escaped + `"` +} diff --git a/internal/protocols/hysteria2/sys_other.go b/internal/protocols/hysteria2/sys_other.go new file mode 100644 index 0000000..b21a9ea --- /dev/null +++ b/internal/protocols/hysteria2/sys_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package hysteria2 + +import "os/exec" + +func applySysProcAttr(cmd *exec.Cmd) {} diff --git a/internal/protocols/hysteria2/sys_windows.go b/internal/protocols/hysteria2/sys_windows.go new file mode 100644 index 0000000..1a581c4 --- /dev/null +++ b/internal/protocols/hysteria2/sys_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package hysteria2 + +import ( + "os/exec" + "syscall" +) + +func applySysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/protocols/hysteria2/uri.go b/internal/protocols/hysteria2/uri.go new file mode 100644 index 0000000..16309ec --- /dev/null +++ b/internal/protocols/hysteria2/uri.go @@ -0,0 +1,113 @@ +package hysteria2 + +import ( + "fmt" + "net/url" + "strings" +) + +// NormalizeShareLink accepts hysteria2:// or hy2:// share links (or already-normalized URI). +// Returns a canonical hysteria2:// URI suitable for the official client's `server` field. +func NormalizeShareLink(raw string) (string, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("empty hysteria2 URI") + } + + remark := "" + if i := strings.IndexByte(raw, '#'); i >= 0 { + remark = strings.TrimSpace(raw[i+1:]) + if u, err := url.QueryUnescape(remark); err == nil { + remark = u + } + raw = raw[:i] + } + + lower := strings.ToLower(raw) + switch { + case strings.HasPrefix(lower, "hy2://"): + raw = "hysteria2://" + raw[len("hy2://"):] + case strings.HasPrefix(lower, "hysteria2://"): + // ok + default: + return "", "", fmt.Errorf("ожидалась ссылка hysteria2:// или hy2://") + } + + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("parse hysteria2 URI: %w", err) + } + if u.Host == "" { + return "", "", fmt.Errorf("hysteria2 URI missing host") + } + if u.Scheme != "hysteria2" { + u.Scheme = "hysteria2" + } + return u.String(), remark, nil +} + +// Detect returns true if raw looks like a Hysteria 2 share link. +func Detect(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(lower, "hysteria2://") || strings.HasPrefix(lower, "hy2://") +} + +// HostPort extracts host and a single port for reachability checks. +func HostPort(shareURI string) (host, port string, err error) { + normalized, _, err := NormalizeShareLink(shareURI) + if err != nil { + return "", "", err + } + u, err := url.Parse(normalized) + if err != nil { + return "", "", err + } + host = u.Hostname() + port = u.Port() + if port == "" { + // Multi-port may be in Hostname() for weird parses; Host may be "ex.com:443,5000-6000" + hostField := u.Host + if h, p, ok := splitMultiPortHost(hostField); ok { + return h, p, nil + } + port = "443" + } else if strings.Contains(port, ",") || strings.Contains(port, "-") { + port = firstPortToken(port) + } + if host == "" { + return "", "", fmt.Errorf("нет хоста") + } + if port == "" { + port = "443" + } + return host, port, nil +} + +func splitMultiPortHost(hostField string) (host, port string, ok bool) { + // example.com:443,5000-6000 + i := strings.LastIndex(hostField, ":") + if i < 0 { + return "", "", false + } + host = hostField[:i] + port = firstPortToken(hostField[i+1:]) + if host == "" || port == "" { + return "", "", false + } + return host, port, true +} + +func firstPortToken(ports string) string { + ports = strings.TrimSpace(ports) + if ports == "" { + return "" + } + part := ports + if i := strings.IndexByte(ports, ','); i >= 0 { + part = ports[:i] + } + if i := strings.IndexByte(part, '-'); i >= 0 { + part = part[:i] + } + return strings.TrimSpace(part) +} diff --git a/internal/protocols/hysteria2/uri_test.go b/internal/protocols/hysteria2/uri_test.go new file mode 100644 index 0000000..691c632 --- /dev/null +++ b/internal/protocols/hysteria2/uri_test.go @@ -0,0 +1,23 @@ +package hysteria2 + +import "testing" + +func TestNormalizeShareLink(t *testing.T) { + got, remark, err := NormalizeShareLink("hy2://secret@example.com:443/?sni=example.com&insecure=1#EU") + if err != nil { + t.Fatal(err) + } + if remark != "EU" { + t.Fatalf("remark=%q", remark) + } + if !Detect(got) { + t.Fatalf("not detected: %s", got) + } + host, port, err := HostPort(got) + if err != nil { + t.Fatal(err) + } + if host != "example.com" || port != "443" { + t.Fatalf("host=%q port=%q", host, port) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 2675be7..56f3abc 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -18,7 +18,7 @@ import ( ) // CurrentVersion is the shipped client version. -const CurrentVersion = "1.1.3" +const CurrentVersion = "1.2.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" diff --git a/server/update.json b/server/update.json index 0978b03..32845ae 100644 --- a/server/update.json +++ b/server/update.json @@ -1,7 +1,10 @@ { - "version": "1.1.3", - "notes": "Тестовый релиз автообновления 1.1.3", + "version": "1.2.0", + "notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", + "platform": "windows-amd64", + "os": "windows", + "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "652572f7dfefb59d62ac291f8cabb0edc9f1039ae4791f7c2b2e6bfd3a8b9fcb", + "sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "mandatory": false } \ No newline at end of file diff --git a/versioninfo.json b/versioninfo.json index 1767a11..545b2e6 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 1, "Minor": 1, "Patch": 3, "Build": 0 }, - "ProductVersion": { "Major": 1, "Minor": 1, "Patch": 3, "Build": 0 }, + "FileVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -10,12 +10,12 @@ }, "StringFileInfo": { "CompanyName": "Navis", - "FileDescription": "Navis — NaiveProxy client", - "FileVersion": "1.1.3.0", + "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows", + "FileVersion": "1.2.0.0", "InternalName": "Navis", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "1.1.3.0" + "ProductVersion": "1.2.0.0" }, "VarFileInfo": { "Translation": {