diff --git a/README.md b/README.md index c561826..0ef361a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Navis -Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) + [AmneziaWG 2.0](https://docs.amnezia.org/documentation/amnezia-wg/) с профилями, пингом, URL-подпиской и автообновлением. +Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) + [AmneziaWG 2.0](https://docs.amnezia.org/documentation/amnezia-wg/) + [Xray](https://github.com/XTLS/Xray-core) (VLESS / VMess / Trojan) с профилями, пингом, URL-подпиской и автообновлением. ## Платформы @@ -210,6 +210,9 @@ https://evilfox.win/ Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси. +В 1.9.0: +- VLESS / VMess / Trojan через официальный Xray-core (кнопка «Установить cores»). + В 1.8.0: - автопроверка обновлений при старте и установка с автоперезапуском; - AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1–S4, H1–H4, I1–I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP. diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index 946d3fa..22cd3ab 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -24,6 +24,7 @@ import ( "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" + "vpnclient/internal/protocols/xray" "vpnclient/internal/update" ) @@ -176,6 +177,10 @@ func (a *app) getState() (uiState, error) { if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil { corePath, coreReady = path, true } + case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: + if path, err := xray.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 diff --git a/cmd/vpnclient/main.go b/cmd/vpnclient/main.go index acfbd81..8ff9b49 100644 --- a/cmd/vpnclient/main.go +++ b/cmd/vpnclient/main.go @@ -15,6 +15,7 @@ import ( "vpnclient/internal/core" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" + "vpnclient/internal/protocols/xray" "vpnclient/internal/update" ) @@ -58,7 +59,7 @@ func main() { } func printUsage() { - fmt.Fprintf(os.Stderr, `Navis VPN client (NaiveProxy + Hysteria 2) — Windows / macOS + fmt.Fprintf(os.Stderr, `Navis VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan) — Windows / macOS Usage: navis init [-config configs/config.json] @@ -69,7 +70,7 @@ Usage: navis check-update navis apply-update -Share links: naive+https://… hysteria2://… hy2://… +Share links: naive+https://… hysteria2://… vless://… vmess://… trojan://… awg .conf On macOS system proxy uses networksetup (HTTP + SOCKS). Local listens: socks://127.0.0.1:1080 http://127.0.0.1:1081 @@ -128,6 +129,12 @@ func runInstallCore(args []string) int { return 1 } fmt.Printf("hysteria2 core ready: %s\n", pathHy2) + pathXray, err := xray.EnsureBinary(binDir) + if err != nil { + fmt.Fprintf(os.Stderr, "install-core xray: %v\n", err) + return 1 + } + fmt.Printf("xray core ready: %s\n", pathXray) return 0 } diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 4f910f0..ed86355 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/darwin-amd64/Navis b/dist/navis-release/darwin-amd64/Navis index e8c11ed..5b0f393 100644 Binary files a/dist/navis-release/darwin-amd64/Navis and b/dist/navis-release/darwin-amd64/Navis differ diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis index c259c66..520364d 100644 Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 745cb66..980cd12 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,28 +1,28 @@ { - "version": "1.8.1", - "notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new", + "version": "1.9.0", + "notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54", + "sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1", + "sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45", "os": "darwin", "arch": "amd64" } diff --git a/dist/update.json b/dist/update.json index 745cb66..980cd12 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,28 +1,28 @@ { - "version": "1.8.1", - "notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new", + "version": "1.9.0", + "notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54", + "sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1", + "sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45", "os": "darwin", "arch": "amd64" } diff --git a/internal/appui/index.html b/internal/appui/index.html index ba5c92b..23cd613 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -374,7 +374,7 @@
- +
@@ -456,7 +456,7 @@
- +

Загрузка…

@@ -480,7 +480,7 @@
- +
@@ -678,9 +678,9 @@ if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy); detail = parts.join(" · "); } else if (state.core_ready === false) { - detail = "Сначала установите cores (naive / hysteria2)"; + detail = "Сначала установите cores (naive / hy2 / xray)"; } else { - detail = "Вставьте ссылку naive или hysteria2://"; + detail = "Вставьте ссылку: naive / hy2 / vless / vmess / trojan / awg"; } if (!busy && Date.now() > metaHoldUntil) { setMeta(detail, state.core_ready === false ? "err" : ""); diff --git a/internal/config/config.go b/internal/config/config.go index 67b2efc..a9e88c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,9 @@ const ( ProtocolNaive Protocol = "naive" ProtocolHysteria2 Protocol = "hysteria2" ProtocolAWG Protocol = "awg" + ProtocolVLESS Protocol = "vless" + ProtocolVMess Protocol = "vmess" + ProtocolTrojan Protocol = "trojan" ) // Config is the top-level client configuration. @@ -182,7 +185,7 @@ func (c *Config) Validate() error { return fmt.Errorf("config: profile[%d] missing name", i) } switch p.Protocol { - case ProtocolNaive, ProtocolHysteria2, ProtocolAWG: + case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan: // Proxy may be empty until the user pastes a share link / conf in the UI. case "": c.Profiles[i].Protocol = ProtocolNaive diff --git a/internal/config/detect.go b/internal/config/detect.go index acaf276..5c98907 100644 --- a/internal/config/detect.go +++ b/internal/config/detect.go @@ -8,6 +8,12 @@ func DetectProtocol(raw string) Protocol { switch { case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"): return ProtocolHysteria2 + case strings.HasPrefix(lower, "vless://"): + return ProtocolVLESS + case strings.HasPrefix(lower, "vmess://"): + return ProtocolVMess + case strings.HasPrefix(lower, "trojan://"): + return ProtocolTrojan case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"), strings.HasPrefix(lower, "wireguard://"): return ProtocolAWG diff --git a/internal/core/manager.go b/internal/core/manager.go index 8fcd688..d5fdd4e 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -18,6 +18,7 @@ import ( "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" + "vpnclient/internal/protocols/xray" "vpnclient/internal/subscription" "vpnclient/internal/sysproxy" ) @@ -412,7 +413,7 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) { return 0, err } if len(items) == 0 { - return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg)") + return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)") } m.mu.Lock() @@ -466,6 +467,8 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) { return hysteria2.New(m.stderr), nil case config.ProtocolAWG: return awg.New(m.stderr), nil + case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: + return xray.New(m.stderr), nil default: return nil, fmt.Errorf("unsupported protocol %q", p) } @@ -483,12 +486,14 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) { return hysteria2.EnsureBinary(m.binDir) case config.ProtocolAWG: return awg.EnsureBinary(m.binDir) + case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: + return xray.EnsureBinary(m.binDir) default: return naive.EnsureBinary(m.binDir) } } -// EnsureAllCores installs naive + hysteria2 cores (AWG is embedded). +// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded). func (m *Manager) EnsureAllCores() (map[string]string, error) { out := map[string]string{} p1, err := naive.EnsureBinary(m.binDir) @@ -506,5 +511,10 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) { return out, fmt.Errorf("awg: %w", err) } out["awg"] = p3 + p4, err := xray.EnsureBinary(m.binDir) + if err != nil { + return out, fmt.Errorf("xray: %w", err) + } + out["xray"] = p4 return out, nil } diff --git a/internal/linknorm/normalize.go b/internal/linknorm/normalize.go index 9ccb9df..3966b2d 100644 --- a/internal/linknorm/normalize.go +++ b/internal/linknorm/normalize.go @@ -8,6 +8,7 @@ import ( "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" + "vpnclient/internal/protocols/xray" ) // Normalize normalizes a share/proxy URI for the given protocol (or auto-detect). @@ -26,11 +27,25 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c case config.ProtocolAWG: u, rem, err := awg.NormalizeShareLink(raw) return u, config.ProtocolAWG, rem, err + case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan: + u, rem, err := xray.NormalizeShareLink(raw) + if err != nil { + return "", proto, "", err + } + detected = xray.DetectProtocol(u) + if detected == "" { + detected = proto + } + return u, detected, rem, nil case config.ProtocolNaive, "": if awg.Detect(raw) { u, rem, err := awg.NormalizeShareLink(raw) return u, config.ProtocolAWG, rem, err } + if xray.Detect(raw) { + u, rem, err := xray.NormalizeShareLink(raw) + return u, xray.DetectProtocol(raw), rem, err + } u, rem, err := naive.ParseShareLink(raw) if err != nil { if hysteria2.Detect(raw) { diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 04c0011..65e3543 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -11,6 +11,7 @@ import ( "vpnclient/internal/linknorm" "vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/hysteria2" + "vpnclient/internal/protocols/xray" ) // Result is a reachability measurement to a proxy host. @@ -39,6 +40,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI) + isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI) var host, port string if isAWG { h, p, err := awg.HostPort(proxyURI) @@ -47,6 +49,13 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU return res } host, port = h, p + } else if isXray { + h, p, err := xray.HostPort(proxyURI) + if err != nil { + res.Error = err.Error() + return res + } + host, port = h, p } else if hy2 { h, p, err := hysteria2.HostPort(proxyURI) if err != nil { diff --git a/internal/protocols/xray/binary.go b/internal/protocols/xray/binary.go new file mode 100644 index 0000000..9a87857 --- /dev/null +++ b/internal/protocols/xray/binary.go @@ -0,0 +1,210 @@ +package xray + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/latest" + +// ResolveBinary finds xray executable. +func ResolveBinary(binDir string) (string, error) { + for _, name := range candidateNames() { + candidates := []string{ + filepath.Join(binDir, name), + filepath.Join(binDir, "xray", name), + } + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + 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("xray binary not found in %s; run install-core", binDir) +} + +func candidateNames() []string { + if runtime.GOOS == "windows" { + return []string{"xray.exe", "Xray.exe"} + } + return []string{"xray", "Xray"} +} + +// EnsureBinary downloads official Xray-core 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 := releaseZipName() + if err != nil { + return "", err + } + fmt.Fprintf(os.Stderr, "downloading official Xray-core (%s)...\n", want) + name, url, err := findReleaseAsset(want) + if err != nil { + return "", err + } + zipPath := filepath.Join(binDir, name+".download") + _ = os.Remove(zipPath) + if err := downloadFile(zipPath, url); err != nil { + return "", err + } + defer os.Remove(zipPath) + + destName := "xray" + if runtime.GOOS == "windows" { + destName = "xray.exe" + } + dest := filepath.Join(binDir, destName) + if err := extractNamedFromZip(zipPath, destName, dest); err != nil { + // Some zips nest the binary; try case-insensitive match. + if err2 := extractXrayFromZip(zipPath, dest); err2 != nil { + return "", fmt.Errorf("%v; %w", err, err2) + } + } + _ = os.Chmod(dest, 0o755) + return ResolveBinary(binDir) +} + +func releaseZipName() (string, error) { + switch { + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + return "Xray-windows-64.zip", nil + case runtime.GOOS == "windows" && runtime.GOARCH == "arm64": + return "Xray-windows-arm64-v8a.zip", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + return "Xray-macos-64.zip", nil + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + return "Xray-macos-arm64-v8a.zip", nil + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + return "Xray-linux-64.zip", nil + case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + return "Xray-linux-arm64-v8a.zip", nil + default: + return "", fmt.Errorf("unsupported platform %s/%s for xray 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 + } + for _, a := range rel.Assets { + if a.Name == exactName { + return a.Name, a.BrowserDownloadURL, nil + } + } + for _, a := range rel.Assets { + if strings.EqualFold(a.Name, exactName) { + return a.Name, a.BrowserDownloadURL, nil + } + } + return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName) +} + +func downloadFile(path, url string) error { + client := &http.Client{Timeout: 15 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: %s", url, resp.Status) + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, io.LimitReader(resp.Body, 120<<20)) + return err +} + +func extractNamedFromZip(zipPath, wantName, dest string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + wantBase := strings.ToLower(filepath.Base(wantName)) + for _, f := range r.File { + base := strings.ToLower(filepath.Base(f.Name)) + if base != wantBase { + continue + } + return writeZipFile(f, dest) + } + return fmt.Errorf("%s not found in zip", wantName) +} + +func extractXrayFromZip(zipPath, dest string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + for _, f := range r.File { + base := strings.ToLower(filepath.Base(f.Name)) + if base == "xray" || base == "xray.exe" { + return writeZipFile(f, dest) + } + } + return fmt.Errorf("xray binary not found in archive") +} + +func writeZipFile(f *zip.File, dest string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + _ = os.Remove(dest) + 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, io.LimitReader(rc, 80<<20)) + return err +} diff --git a/internal/protocols/xray/config_write.go b/internal/protocols/xray/config_write.go new file mode 100644 index 0000000..b4809a9 --- /dev/null +++ b/internal/protocols/xray/config_write.go @@ -0,0 +1,286 @@ +package xray + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "vpnclient/internal/config" +) + +// writeRuntimeConfig builds an Xray config.json for SOCKS+HTTP inbounds and one proxy outbound. +func writeRuntimeConfig(path string, profile config.Profile) error { + link, err := Parse(profile.Proxy) + if err != nil { + return err + } + socksHost, socksPort := "127.0.0.1", 1080 + httpHost, httpPort := "127.0.0.1", 1081 + if hp, ok := profile.SOCKSListenHostPort(); ok { + h, p, err := splitHostPort(hp, 1080) + if err == nil { + socksHost, socksPort = h, p + } + } + if hp, ok := profile.HTTPListenHostPort(); ok { + h, p, err := splitHostPort(hp, 1081) + if err == nil { + httpHost, httpPort = h, p + } + } + + outbound, err := buildOutbound(link) + if err != nil { + return err + } + + cfg := map[string]any{ + "log": map[string]any{"loglevel": "warning"}, + "inbounds": []any{ + map[string]any{ + "tag": "socks-in", + "listen": socksHost, + "port": socksPort, + "protocol": "socks", + "settings": map[string]any{"udp": true, "auth": "noauth"}, + }, + map[string]any{ + "tag": "http-in", + "listen": httpHost, + "port": httpPort, + "protocol": "http", + }, + }, + "outbounds": []any{ + outbound, + map[string]any{"protocol": "freedom", "tag": "direct"}, + map[string]any{"protocol": "blackhole", "tag": "block"}, + }, + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func buildOutbound(link Link) (map[string]any, error) { + stream, err := buildStreamSettings(link) + if err != nil { + return nil, err + } + switch link.Protocol { + case config.ProtocolVLESS: + user := map[string]any{ + "id": link.UUID, + "encryption": firstNonEmpty(link.Security, "none"), + } + if link.Flow != "" { + user["flow"] = link.Flow + } + settings := map[string]any{ + "vnext": []any{ + map[string]any{ + "address": link.Address, + "port": link.Port, + "users": []any{user}, + }, + }, + } + if link.PacketEncoding != "" { + settings["packetEncoding"] = link.PacketEncoding + } + return map[string]any{ + "tag": "proxy", + "protocol": "vless", + "settings": settings, + "streamSettings": stream, + }, nil + case config.ProtocolVMess: + user := map[string]any{ + "id": link.UUID, + "alterId": link.AlterID, + "security": firstNonEmpty(link.Security, "auto"), + } + return map[string]any{ + "tag": "proxy", + "protocol": "vmess", + "settings": map[string]any{ + "vnext": []any{ + map[string]any{ + "address": link.Address, + "port": link.Port, + "users": []any{user}, + }, + }, + }, + "streamSettings": stream, + }, nil + case config.ProtocolTrojan: + return map[string]any{ + "tag": "proxy", + "protocol": "trojan", + "settings": map[string]any{ + "servers": []any{ + map[string]any{ + "address": link.Address, + "port": link.Port, + "password": link.UUID, + }, + }, + }, + "streamSettings": stream, + }, nil + default: + return nil, fmt.Errorf("unsupported xray protocol %q", link.Protocol) + } +} + +func buildStreamSettings(link Link) (map[string]any, error) { + network := strings.ToLower(firstNonEmpty(link.Network, "tcp")) + stream := map[string]any{"network": network} + + switch network { + case "ws", "websocket": + stream["network"] = "ws" + ws := map[string]any{} + if link.Path != "" { + ws["path"] = link.Path + } + if link.Host != "" { + ws["headers"] = map[string]any{"Host": link.Host} + } + stream["wsSettings"] = ws + case "grpc", "gun": + stream["network"] = "grpc" + grpc := map[string]any{"serviceName": firstNonEmpty(link.ServiceName, link.Path)} + if link.Mode != "" { + grpc["multiMode"] = strings.EqualFold(link.Mode, "multi") + } + stream["grpcSettings"] = grpc + case "h2", "http": + stream["network"] = "h2" + h2 := map[string]any{} + if link.Path != "" { + h2["path"] = link.Path + } + if link.Host != "" { + h2["host"] = strings.Split(link.Host, ",") + } + stream["httpSettings"] = h2 + case "httpupgrade": + stream["network"] = "httpupgrade" + hu := map[string]any{} + if link.Path != "" { + hu["path"] = link.Path + } + if link.Host != "" { + hu["host"] = link.Host + } + stream["httpupgradeSettings"] = hu + case "xhttp", "splithttp": + stream["network"] = "xhttp" + xh := map[string]any{} + if link.Path != "" { + xh["path"] = link.Path + } + if link.Host != "" { + xh["host"] = link.Host + } + stream["xhttpSettings"] = xh + default: // tcp + stream["network"] = "tcp" + if link.Type != "" && link.Type != "none" { + stream["tcpSettings"] = map[string]any{ + "header": map[string]any{"type": link.Type}, + } + } + } + + sec := strings.ToLower(firstNonEmpty(link.TLS, "none")) + switch sec { + case "", "none", "0", "false": + stream["security"] = "none" + case "tls": + stream["security"] = "tls" + tls := map[string]any{ + "serverName": firstNonEmpty(link.SNI, link.Host, link.Address), + "allowInsecure": link.AllowInsecure, + } + if link.FP != "" { + tls["fingerprint"] = link.FP + } + if link.ALPN != "" { + tls["alpn"] = splitCSV(link.ALPN) + } + stream["tlsSettings"] = tls + case "reality": + stream["security"] = "reality" + reality := map[string]any{ + "serverName": firstNonEmpty(link.SNI, link.Host, link.Address), + "fingerprint": firstNonEmpty(link.FP, "chrome"), + "publicKey": link.PBK, + "shortId": link.SID, + "spiderX": firstNonEmpty(link.SPX, ""), + } + if link.PBK == "" { + return nil, fmt.Errorf("reality: нужен pbk (publicKey)") + } + stream["realitySettings"] = reality + default: + stream["security"] = sec + } + return stream, nil +} + +func splitHostPort(hp string, defPort int) (string, int, error) { + host, portStr, err := netSplitHostPort(hp) + if err != nil { + return "", 0, err + } + if portStr == "" { + return host, defPort, nil + } + p, err := strconv.Atoi(portStr) + if err != nil { + return "", 0, err + } + return host, p, nil +} + +func netSplitHostPort(hp string) (host, port string, err error) { + if strings.HasPrefix(hp, "[") { + return splitBracket(hp) + } + if i := strings.LastIndex(hp, ":"); i >= 0 { + return hp[:i], hp[i+1:], nil + } + return hp, "", nil +} + +func splitBracket(hp string) (string, string, error) { + end := strings.Index(hp, "]") + if end < 0 { + return "", "", fmt.Errorf("bad host:port") + } + host := hp[1:end] + rest := hp[end+1:] + if strings.HasPrefix(rest, ":") { + return host, rest[1:], nil + } + return host, "", nil +} + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/protocols/xray/engine.go b/internal/protocols/xray/engine.go new file mode 100644 index 0000000..7d77ee5 --- /dev/null +++ b/internal/protocols/xray/engine.go @@ -0,0 +1,185 @@ +package xray + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "vpnclient/internal/config" +) + +// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan. +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 + proto config.Protocol +} + +func New(stderr io.Writer) *Engine { + if stderr == nil { + stderr = os.Stderr + } + return &Engine{stderr: stderr} +} + +func (e *Engine) Protocol() config.Protocol { + if e.proto != "" { + return e.proto + } + return config.ProtocolVLESS +} + +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("xray: already running") + } + + link, err := Parse(profile.Proxy) + if err != nil { + return err + } + e.proto = link.Protocol + + bin, err := ResolveBinary(binDir) + if err != nil { + return err + } + + workdir, err := os.MkdirTemp("", "vpnclient-xray-*") + if err != nil { + return fmt.Errorf("xray: 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, "run", "-c", cfgPath) + cmd.Dir = workdir + cmd.Stdout = e.stderr + cmd.Stderr = e.stderr + applySysProcAttr(cmd) + + if err := cmd.Start(); err != nil { + cancel() + os.RemoveAll(workdir) + return fmt.Errorf("xray: 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, "xray: 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("xray: process exited immediately; check link and install-core (xray)") + 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("xray: 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() +} diff --git a/internal/protocols/xray/sys_other.go b/internal/protocols/xray/sys_other.go new file mode 100644 index 0000000..21973f0 --- /dev/null +++ b/internal/protocols/xray/sys_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package xray + +import "os/exec" + +func applySysProcAttr(cmd *exec.Cmd) {} diff --git a/internal/protocols/xray/sys_windows.go b/internal/protocols/xray/sys_windows.go new file mode 100644 index 0000000..0b61597 --- /dev/null +++ b/internal/protocols/xray/sys_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package xray + +import ( + "os/exec" + "syscall" +) + +func applySysProcAttr(cmd *exec.Cmd) { + const createNoWindow = 0x08000000 + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow} +} diff --git a/internal/protocols/xray/uri.go b/internal/protocols/xray/uri.go new file mode 100644 index 0000000..4f6e858 --- /dev/null +++ b/internal/protocols/xray/uri.go @@ -0,0 +1,364 @@ +package xray + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "vpnclient/internal/config" +) + +// Link is a normalized VLESS / VMess / Trojan share link. +type Link struct { + Protocol config.Protocol + Raw string + Remark string + Address string + Port int + UUID string // vless/vmess id, or trojan password + AlterID int + Security string // encryption for vmess (auto/aes-128-gcm/…) or vless encryption + Flow string + Network string // tcp/ws/grpc/h2/httpupgrade/xhttp/splithttp + Type string // header type for tcp + Host string // ws/http host header + Path string + TLS string // none/tls/reality + SNI string + ALPN string + FP string + PBK string // reality public key + SID string // reality shortId + SPX string // reality spiderX + ServiceName string // grpc + Mode string // grpc multi/gun + AllowInsecure bool + PacketEncoding string // xudp/packetaddr for vless +} + +// Detect reports whether raw is a vless/vmess/trojan share link. +func Detect(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(lower, "vless://") || + strings.HasPrefix(lower, "vmess://") || + strings.HasPrefix(lower, "trojan://") +} + +// DetectProtocol returns the specific protocol or "". +func DetectProtocol(raw string) config.Protocol { + lower := strings.ToLower(strings.TrimSpace(raw)) + switch { + case strings.HasPrefix(lower, "vless://"): + return config.ProtocolVLESS + case strings.HasPrefix(lower, "vmess://"): + return config.ProtocolVMess + case strings.HasPrefix(lower, "trojan://"): + return config.ProtocolTrojan + default: + return "" + } +} + +// NormalizeShareLink parses and returns a canonical share URI + remark. +func NormalizeShareLink(raw string) (normalized string, remark string, err error) { + link, err := Parse(raw) + if err != nil { + return "", "", err + } + return link.Raw, link.Remark, nil +} + +// Parse accepts vless://, vmess://, trojan:// share links. +func Parse(raw string) (Link, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Link{}, fmt.Errorf("пустая ссылка") + } + lower := strings.ToLower(raw) + switch { + case strings.HasPrefix(lower, "vless://"): + return parseVLESS(raw) + case strings.HasPrefix(lower, "vmess://"): + return parseVMess(raw) + case strings.HasPrefix(lower, "trojan://"): + return parseTrojan(raw) + default: + return Link{}, fmt.Errorf("ожидалась ссылка vless://, vmess:// или trojan://") + } +} + +// HostPort extracts server host/port for ping. +func HostPort(raw string) (host, port string, err error) { + link, err := Parse(raw) + if err != nil { + return "", "", err + } + if link.Address == "" { + return "", "", fmt.Errorf("нет хоста") + } + p := strconv.Itoa(link.Port) + if link.Port <= 0 { + p = "443" + } + return link.Address, p, nil +} + +func parseVLESS(raw string) (Link, error) { + remark := "" + body := raw + if i := strings.IndexByte(body, '#'); i >= 0 { + remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:])) + body = body[:i] + } + u, err := url.Parse(body) + if err != nil { + return Link{}, fmt.Errorf("parse vless: %w", err) + } + host := u.Hostname() + port := 443 + if p := u.Port(); p != "" { + port, _ = strconv.Atoi(p) + } + uuid := "" + if u.User != nil { + uuid = u.User.Username() + } + if host == "" || uuid == "" { + return Link{}, fmt.Errorf("vless: нужен uuid@host:port") + } + q := u.Query() + link := Link{ + Protocol: config.ProtocolVLESS, + Raw: rebuildURI("vless", uuid, "", host, port, q, remark), + Remark: remark, + Address: host, + Port: port, + UUID: uuid, + Security: firstNonEmpty(q.Get("encryption"), "none"), + Flow: q.Get("flow"), + Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"), + Type: q.Get("headerType"), + Host: firstNonEmpty(q.Get("host"), q.Get("authority")), + Path: firstNonEmpty(q.Get("path"), q.Get("serviceName")), + TLS: firstNonEmpty(q.Get("security"), "none"), + SNI: firstNonEmpty(q.Get("sni"), q.Get("serverName")), + ALPN: q.Get("alpn"), + FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")), + PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")), + SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")), + SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")), + ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")), + Mode: q.Get("mode"), + AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")), + PacketEncoding: q.Get("packetEncoding"), + } + if link.Network == "grpc" && link.ServiceName == "" { + link.ServiceName = link.Path + } + return link, nil +} + +func parseTrojan(raw string) (Link, error) { + remark := "" + body := raw + if i := strings.IndexByte(body, '#'); i >= 0 { + remark, _ = url.QueryUnescape(strings.TrimSpace(body[i+1:])) + body = body[:i] + } + u, err := url.Parse(body) + if err != nil { + return Link{}, fmt.Errorf("parse trojan: %w", err) + } + host := u.Hostname() + port := 443 + if p := u.Port(); p != "" { + port, _ = strconv.Atoi(p) + } + password := "" + if u.User != nil { + password = u.User.Username() + if p, ok := u.User.Password(); ok && p != "" { + password = password + ":" + p + } + } + if host == "" || password == "" { + return Link{}, fmt.Errorf("trojan: нужен password@host:port") + } + q := u.Query() + tls := firstNonEmpty(q.Get("security"), "tls") + if tls == "" || tls == "none" { + tls = "tls" + } + link := Link{ + Protocol: config.ProtocolTrojan, + Raw: rebuildURI("trojan", password, "", host, port, q, remark), + Remark: remark, + Address: host, + Port: port, + UUID: password, + Network: firstNonEmpty(q.Get("type"), q.Get("network"), "tcp"), + Type: q.Get("headerType"), + Host: firstNonEmpty(q.Get("host"), q.Get("authority")), + Path: q.Get("path"), + TLS: tls, + SNI: firstNonEmpty(q.Get("sni"), q.Get("peer"), q.Get("serverName")), + ALPN: q.Get("alpn"), + FP: firstNonEmpty(q.Get("fp"), q.Get("fingerprint")), + PBK: firstNonEmpty(q.Get("pbk"), q.Get("publicKey")), + SID: firstNonEmpty(q.Get("sid"), q.Get("shortId")), + SPX: firstNonEmpty(q.Get("spx"), q.Get("spiderX")), + ServiceName: firstNonEmpty(q.Get("serviceName"), q.Get("path")), + Mode: q.Get("mode"), + AllowInsecure: truthy(q.Get("allowInsecure")) || truthy(q.Get("insecure")), + } + return link, nil +} + +type vmessShare struct { + V any `json:"v"` + PS string `json:"ps"` + Add string `json:"add"` + Port any `json:"port"` + ID string `json:"id"` + Aid any `json:"aid"` + Scy string `json:"scy"` + Net string `json:"net"` + Type string `json:"type"` + Host string `json:"host"` + Path string `json:"path"` + TLS string `json:"tls"` + SNI string `json:"sni"` + ALPN string `json:"alpn"` + FP string `json:"fp"` +} + +func parseVMess(raw string) (Link, error) { + body := strings.TrimSpace(raw) + if i := strings.Index(strings.ToLower(body), "vmess://"); i >= 0 { + body = body[i+len("vmess://"):] + } + if i := strings.IndexByte(body, '#'); i >= 0 { + body = body[:i] + } + body = strings.TrimSpace(body) + decoded, err := decodeBase64Flexible(body) + if err != nil { + return Link{}, fmt.Errorf("vmess base64: %w", err) + } + var m vmessShare + if err := json.Unmarshal([]byte(decoded), &m); err != nil { + return Link{}, fmt.Errorf("vmess json: %w", err) + } + port := anyToInt(m.Port, 443) + aid := anyToInt(m.Aid, 0) + if m.Add == "" || m.ID == "" { + return Link{}, fmt.Errorf("vmess: нет add/id") + } + tls := strings.ToLower(strings.TrimSpace(m.TLS)) + if tls == "1" || tls == "true" { + tls = "tls" + } + if tls == "" { + tls = "none" + } + link := Link{ + Protocol: config.ProtocolVMess, + Remark: m.PS, + Address: m.Add, + Port: port, + UUID: m.ID, + AlterID: aid, + Security: firstNonEmpty(m.Scy, "auto"), + Network: firstNonEmpty(m.Net, "tcp"), + Type: m.Type, + Host: m.Host, + Path: m.Path, + TLS: tls, + SNI: firstNonEmpty(m.SNI, m.Host), + ALPN: m.ALPN, + FP: m.FP, + } + // Rebuild canonical vmess:// for storage. + out, _ := json.Marshal(map[string]any{ + "v": "2", "ps": link.Remark, "add": link.Address, "port": link.Port, + "id": link.UUID, "aid": link.AlterID, "scy": link.Security, "net": link.Network, + "type": link.Type, "host": link.Host, "path": link.Path, "tls": link.TLS, + "sni": link.SNI, "alpn": link.ALPN, "fp": link.FP, + }) + link.Raw = "vmess://" + base64.StdEncoding.EncodeToString(out) + return link, nil +} + +func rebuildURI(scheme, user, pass, host string, port int, q url.Values, remark string) string { + u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(host, strconv.Itoa(port))} + if pass != "" { + u.User = url.UserPassword(user, pass) + } else { + u.User = url.User(user) + } + if len(q) > 0 { + u.RawQuery = q.Encode() + } + s := u.String() + if remark != "" { + s += "#" + url.PathEscape(remark) + } + return s +} + +func decodeBase64Flexible(s string) (string, error) { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "-", "+") + s = strings.ReplaceAll(s, "_", "/") + switch len(s) % 4 { + case 2: + s += "==" + case 3: + s += "=" + } + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + b, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "=")) + if err != nil { + return "", err + } + } + return string(b), nil +} + +func anyToInt(v any, def int) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case string: + n, err := strconv.Atoi(strings.TrimSpace(t)) + if err == nil { + return n + } + } + return def +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func truthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/internal/protocols/xray/uri_test.go b/internal/protocols/xray/uri_test.go new file mode 100644 index 0000000..555bb55 --- /dev/null +++ b/internal/protocols/xray/uri_test.go @@ -0,0 +1,40 @@ +package xray + +import "testing" + +func TestParseVLESS(t *testing.T) { + raw := "vless://11111111-1111-1111-1111-111111111111@example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.cloudflare.com&fp=chrome&pbk=PUBLIC&sid=abcd&type=tcp#EU" + link, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if link.Protocol != "vless" || link.Address != "example.com" || link.Port != 443 { + t.Fatalf("%+v", link) + } + if link.TLS != "reality" || link.PBK != "PUBLIC" || link.Flow != "xtls-rprx-vision" { + t.Fatalf("%+v", link) + } +} + +func TestParseTrojan(t *testing.T) { + raw := "trojan://secret@1.2.3.4:443?security=tls&sni=example.com&type=ws&path=%2Fws&host=example.com#tr" + link, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if link.UUID != "secret" || link.Network != "ws" || link.Path != "/ws" { + t.Fatalf("%+v", link) + } +} + +func TestParseVMess(t *testing.T) { + // {"v":"2","ps":"n","add":"1.2.3.4","port":"443","id":"11111111-1111-1111-1111-111111111111","aid":"0","scy":"auto","net":"tcp","type":"none","host":"","path":"","tls":"tls","sni":"example.com"} + b64 := "eyJ2IjoiMiIsInBzIjoibiIsImFkZCI6IjEuMi4zLjQiLCJwb3J0IjoiNDQzIiwiaWQiOiIxMTExMTExMS0xMTExLTExMTEtMTExMS0xMTExMTExMTExMTEiLCJhaWQiOiIwIiwic2N5IjoiYXV0byIsIm5ldCI6InRjcCIsInR5cGUiOiJub25lIiwiaG9zdCI6IiIsInBhdGgiOiIiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJleGFtcGxlLmNvbSJ9" + link, err := Parse("vmess://" + b64) + if err != nil { + t.Fatal(err) + } + if link.Address != "1.2.3.4" || link.Port != 443 || link.TLS != "tls" { + t.Fatalf("%+v", link) + } +} diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go index 284e9be..e535485 100644 --- a/internal/subscription/fetch.go +++ b/internal/subscription/fetch.go @@ -26,7 +26,7 @@ type Item struct { } var ( - reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) + reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`) ) diff --git a/internal/update/update.go b/internal/update/update.go index af6b4ce..cd4ed1a 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,7 +17,7 @@ import ( ) // CurrentVersion is the shipped client version. -const CurrentVersion = "1.8.1" +const CurrentVersion = "1.9.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 745cb66..980cd12 100644 --- a/server/update.json +++ b/server/update.json @@ -1,28 +1,28 @@ { - "version": "1.8.1", - "notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new", + "version": "1.9.0", + "notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500", + "sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7", "os": "windows", "arch": "amd64" }, "darwin-arm64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54", + "sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449", "os": "darwin", "arch": "arm64" }, "darwin-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1", + "sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45", "os": "darwin", "arch": "amd64" } diff --git a/versioninfo.json b/versioninfo.json index d5aa168..b1e9fc0 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 1, "Minor": 8, "Patch": 1, "Build": 0 }, - "ProductVersion": { "Major": 1, "Minor": 8, "Patch": 1, "Build": 0 }, + "FileVersion": { "Major": 1, "Minor": 9, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 1, "Minor": 9, "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -10,13 +10,13 @@ }, "StringFileInfo": { "CompanyName": "EvilFox", - "FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2 / AmneziaWG 2.0)", - "FileVersion": "1.8.1.0", + "FileDescription": "Navis VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", + "FileVersion": "1.9.0.0", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "1.8.1.0", + "ProductVersion": "1.9.0.0", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {