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