Release 1.9.0: add VLESS, VMess and Trojan via Xray-core.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Navis
|
# 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-бинарники с сетью и сменой системного прокси.
|
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||||
|
|
||||||
|
В 1.9.0:
|
||||||
|
- VLESS / VMess / Trojan через официальный Xray-core (кнопка «Установить cores»).
|
||||||
|
|
||||||
В 1.8.0:
|
В 1.8.0:
|
||||||
- автопроверка обновлений при старте и установка с автоперезапуском;
|
- автопроверка обновлений при старте и установка с автоперезапуском;
|
||||||
- AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1–S4, H1–H4, I1–I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
|
- AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1–S4, H1–H4, I1–I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
|
"vpnclient/internal/protocols/xray"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,6 +177,10 @@ func (a *app) getState() (uiState, error) {
|
|||||||
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
|
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||||
corePath, coreReady = path, true
|
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:
|
default:
|
||||||
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
|
||||||
corePath, coreReady = path, true
|
corePath, coreReady = path, true
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
|
"vpnclient/internal/protocols/xray"
|
||||||
"vpnclient/internal/update"
|
"vpnclient/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printUsage() {
|
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:
|
Usage:
|
||||||
navis init [-config configs/config.json]
|
navis init [-config configs/config.json]
|
||||||
@@ -69,7 +70,7 @@ Usage:
|
|||||||
navis check-update
|
navis check-update
|
||||||
navis apply-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:
|
On macOS system proxy uses networksetup (HTTP + SOCKS). Local listens:
|
||||||
socks://127.0.0.1:1080 http://127.0.0.1:1081
|
socks://127.0.0.1:1080 http://127.0.0.1:1081
|
||||||
@@ -128,6 +129,12 @@ func runInstallCore(args []string) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Printf("hysteria2 core ready: %s\n", pathHy2)
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+6
-6
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new",
|
"notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54",
|
"sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64"
|
"arch": "arm64"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1",
|
"sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
-6
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new",
|
"notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54",
|
"sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64"
|
"arch": "arm64"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1",
|
"sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="proxy">Ссылка / конфиг сервера</label>
|
<label class="field" for="proxy">Ссылка / конфиг сервера</label>
|
||||||
<textarea id="proxy" rows="4" placeholder="naive+https://… · hysteria2://… · или весь .conf AmneziaWG 2.0 ([Interface]/Jc/I1…)" spellcheck="false"></textarea>
|
<textarea id="proxy" rows="4" placeholder="naive+https://… · hysteria2://… · vless:// · vmess:// · trojan:// · AWG .conf" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="subUrl">URL подписки</label>
|
<label class="field" for="subUrl">URL подписки</label>
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
<button class="action secondary" id="pingBtn" type="button">Пинг серверов</button>
|
<button class="action secondary" id="pingBtn" type="button">Пинг серверов</button>
|
||||||
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
|
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="action secondary" id="coreBtn" type="button">Установить cores (naive + hysteria2)</button>
|
<button class="action secondary" id="coreBtn" type="button">Установить cores (naive + hy2 + xray)</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ping-list" id="pingList"></div>
|
<div class="ping-list" id="pingList"></div>
|
||||||
<p class="meta" id="meta">Загрузка…</p>
|
<p class="meta" id="meta">Загрузка…</p>
|
||||||
@@ -480,7 +480,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field" for="newProxy">Ссылка</label>
|
<label class="field" for="newProxy">Ссылка</label>
|
||||||
<input id="newProxy" type="text" placeholder="hysteria2://… / naive+https://… / awg://… или вставьте .conf" />
|
<input id="newProxy" type="text" placeholder="vless://… / vmess://… / trojan://… / hy2 / naive / awg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -678,9 +678,9 @@
|
|||||||
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
|
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
|
||||||
detail = parts.join(" · ");
|
detail = parts.join(" · ");
|
||||||
} else if (state.core_ready === false) {
|
} else if (state.core_ready === false) {
|
||||||
detail = "Сначала установите cores (naive / hysteria2)";
|
detail = "Сначала установите cores (naive / hy2 / xray)";
|
||||||
} else {
|
} else {
|
||||||
detail = "Вставьте ссылку naive или hysteria2://";
|
detail = "Вставьте ссылку: naive / hy2 / vless / vmess / trojan / awg";
|
||||||
}
|
}
|
||||||
if (!busy && Date.now() > metaHoldUntil) {
|
if (!busy && Date.now() > metaHoldUntil) {
|
||||||
setMeta(detail, state.core_ready === false ? "err" : "");
|
setMeta(detail, state.core_ready === false ? "err" : "");
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const (
|
|||||||
ProtocolNaive Protocol = "naive"
|
ProtocolNaive Protocol = "naive"
|
||||||
ProtocolHysteria2 Protocol = "hysteria2"
|
ProtocolHysteria2 Protocol = "hysteria2"
|
||||||
ProtocolAWG Protocol = "awg"
|
ProtocolAWG Protocol = "awg"
|
||||||
|
ProtocolVLESS Protocol = "vless"
|
||||||
|
ProtocolVMess Protocol = "vmess"
|
||||||
|
ProtocolTrojan Protocol = "trojan"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level client configuration.
|
// 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)
|
return fmt.Errorf("config: profile[%d] missing name", i)
|
||||||
}
|
}
|
||||||
switch p.Protocol {
|
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.
|
// Proxy may be empty until the user pastes a share link / conf in the UI.
|
||||||
case "":
|
case "":
|
||||||
c.Profiles[i].Protocol = ProtocolNaive
|
c.Profiles[i].Protocol = ProtocolNaive
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ func DetectProtocol(raw string) Protocol {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
|
||||||
return ProtocolHysteria2
|
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://"),
|
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
|
||||||
strings.HasPrefix(lower, "wireguard://"):
|
strings.HasPrefix(lower, "wireguard://"):
|
||||||
return ProtocolAWG
|
return ProtocolAWG
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
|
"vpnclient/internal/protocols/xray"
|
||||||
"vpnclient/internal/subscription"
|
"vpnclient/internal/subscription"
|
||||||
"vpnclient/internal/sysproxy"
|
"vpnclient/internal/sysproxy"
|
||||||
)
|
)
|
||||||
@@ -412,7 +413,7 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg)")
|
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -466,6 +467,8 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
|
|||||||
return hysteria2.New(m.stderr), nil
|
return hysteria2.New(m.stderr), nil
|
||||||
case config.ProtocolAWG:
|
case config.ProtocolAWG:
|
||||||
return awg.New(m.stderr), nil
|
return awg.New(m.stderr), nil
|
||||||
|
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||||
|
return xray.New(m.stderr), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported protocol %q", p)
|
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)
|
return hysteria2.EnsureBinary(m.binDir)
|
||||||
case config.ProtocolAWG:
|
case config.ProtocolAWG:
|
||||||
return awg.EnsureBinary(m.binDir)
|
return awg.EnsureBinary(m.binDir)
|
||||||
|
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
||||||
|
return xray.EnsureBinary(m.binDir)
|
||||||
default:
|
default:
|
||||||
return naive.EnsureBinary(m.binDir)
|
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) {
|
func (m *Manager) EnsureAllCores() (map[string]string, error) {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
p1, err := naive.EnsureBinary(m.binDir)
|
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)
|
return out, fmt.Errorf("awg: %w", err)
|
||||||
}
|
}
|
||||||
out["awg"] = p3
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
|
"vpnclient/internal/protocols/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
|
// 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:
|
case config.ProtocolAWG:
|
||||||
u, rem, err := awg.NormalizeShareLink(raw)
|
u, rem, err := awg.NormalizeShareLink(raw)
|
||||||
return u, config.ProtocolAWG, rem, err
|
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, "":
|
case config.ProtocolNaive, "":
|
||||||
if awg.Detect(raw) {
|
if awg.Detect(raw) {
|
||||||
u, rem, err := awg.NormalizeShareLink(raw)
|
u, rem, err := awg.NormalizeShareLink(raw)
|
||||||
return u, config.ProtocolAWG, rem, err
|
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)
|
u, rem, err := naive.ParseShareLink(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if hysteria2.Detect(raw) {
|
if hysteria2.Detect(raw) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
|
"vpnclient/internal/protocols/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result is a reachability measurement to a proxy host.
|
// 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)
|
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
||||||
isAWG := proto == config.ProtocolAWG || awg.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
|
var host, port string
|
||||||
if isAWG {
|
if isAWG {
|
||||||
h, p, err := awg.HostPort(proxyURI)
|
h, p, err := awg.HostPort(proxyURI)
|
||||||
@@ -47,6 +49,13 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
host, port = h, p
|
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 {
|
} else if hy2 {
|
||||||
h, p, err := hysteria2.HostPort(proxyURI)
|
h, p, err := hysteria2.HostPort(proxyURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package xray
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func applySysProcAttr(cmd *exec.Cmd) {}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ type Item struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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"']+)`)
|
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentVersion is the shipped client version.
|
// 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).
|
// 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"
|
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||||
|
|||||||
+6
-6
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"notes": "Исправлено автообновление: замена текущего Navis.exe на месте без .new",
|
"notes": "VLESS / VMess / Trojan через Xray-core; нажмите «Установить cores»",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||||
"sha256": "7265ace71fdd58b86c0c9acf8ee05d3f0182623201987c9d119d9d1ab328f500",
|
"sha256": "bd78ead7711b23a25b1a30278ec381bd762b595e91c4c00f7370c62d785740f7",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||||
"sha256": "9a1f9d99b930cde479f9d28ef37b0f1472ab033f6e9f28da5781617639c17d54",
|
"sha256": "237962c24fb875f3dd2a5ecfad0db5d6837cefb6b691860a3e1d6f0e721f8449",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "arm64"
|
"arch": "arm64"
|
||||||
},
|
},
|
||||||
"darwin-amd64": {
|
"darwin-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||||
"sha256": "9c036f9022761a117021af37778ecd293a3fc2b7438eaf26f804bb01ef4943f1",
|
"sha256": "91430bb85df68b3816d9cc643b1bdc3d022410473c4f87f4d8573bb99259ba45",
|
||||||
"os": "darwin",
|
"os": "darwin",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "Major": 1, "Minor": 8, "Patch": 1, "Build": 0 },
|
"FileVersion": { "Major": 1, "Minor": 9, "Patch": 0, "Build": 0 },
|
||||||
"ProductVersion": { "Major": 1, "Minor": 8, "Patch": 1, "Build": 0 },
|
"ProductVersion": { "Major": 1, "Minor": 9, "Patch": 0, "Build": 0 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
},
|
},
|
||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis VPN client (NaiveProxy / Hysteria 2 / AmneziaWG 2.0)",
|
"FileDescription": "Navis VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "1.8.1.0",
|
"FileVersion": "1.9.0.0",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "1.8.1.0",
|
"ProductVersion": "1.9.0.0",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user