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": {