Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d52913c32 | ||
|
|
0ba662c650 | ||
|
|
19711c3a33 | ||
|
|
497af0e475 | ||
|
|
2a57987aea | ||
|
|
74ffbd87d7 | ||
|
|
46e28aa73f | ||
|
|
329209fa88 | ||
|
|
5f768c7732 | ||
|
|
1144fab54d | ||
|
|
e76c0a0977 | ||
|
|
01fac376ba | ||
|
|
77bd7da861 | ||
|
|
e34312ef9c | ||
|
|
015ded9bd5 |
@@ -4,6 +4,12 @@ bin/
|
||||
!dist/**/*.exe
|
||||
!dist/**/
|
||||
configs/config.json
|
||||
configs/remnawave-api.json
|
||||
configs/account.json
|
||||
dist/navis-release/windows/configs/
|
||||
# Runtime-downloaded protocol cores + local packaging artifact (not release sources)
|
||||
dist/navis-release/windows/bin/
|
||||
dist/navis-release/navis-release.zip
|
||||
configs/.navis-webview/
|
||||
cmd/vpnapp/resource.syso
|
||||
*.syso
|
||||
@@ -11,8 +17,11 @@ cmd/vpnapp/resource.syso
|
||||
navis-update.bat
|
||||
Navis.exe.new
|
||||
Navis-pending.exe
|
||||
EvilFox.exe.new
|
||||
EvilFox-pending.exe
|
||||
*.bak
|
||||
Navis.exe.bak
|
||||
EvilFox.exe.bak
|
||||
|
||||
# MSIX staging / local packages
|
||||
packaging/msix/_staging/
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Navis
|
||||
# EvilFox
|
||||
|
||||
Клиент [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-подпиской и автообновлением.
|
||||
Клиент [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) с профилями, пингом, централизованной выдачей конфигов (Remnawave) и автообновлением.
|
||||
|
||||
> Ранее приложение называлось **Navis**. С 4.0.0 бинарник — `EvilFox.exe`. Каталог релиза `dist/navis-release/` и URL-путь сохранены, чтобы клиенты 3.x продолжали обновляться; в той же папке лежит и `Navis.exe` (копия `EvilFox.exe`) как совместимый алиас.
|
||||
|
||||
## Платформы
|
||||
|
||||
| OS | Артефакты | UI |
|
||||
|----|----------|----|
|
||||
| Windows amd64 | `Navis.exe` | GUI (WebView2) |
|
||||
| Windows amd64 | `EvilFox.exe` (+ `Navis.exe` alias) | GUI (WebView2) |
|
||||
| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) |
|
||||
| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI |
|
||||
| Android | `Navis.apk` | GUI (VpnService) |
|
||||
@@ -210,13 +212,26 @@ Navis — Win32 + WebView2 VPN/прокси-клиент (системный п
|
||||
|
||||
```powershell
|
||||
build.bat
|
||||
.\scripts\pack-msix.ps1
|
||||
.\scripts\pack-msix.ps1 -Version 3.9.0.1
|
||||
```
|
||||
|
||||
Нужен Windows SDK (`MakeAppx.exe`). Перед публикацией подставьте Identity Name / Publisher из Partner Center (см. `packaging/msix/AppxManifest.xml` и [packaging/msix/README.md](packaging/msix/README.md)).
|
||||
|
||||
Сборка из Store **не** самообновляется через in-app updater (детект MSIX package identity) — только обновления Microsoft Store.
|
||||
|
||||
### Remnawave
|
||||
|
||||
Импорт нод из панели [Remnawave](https://remna.st):
|
||||
|
||||
| Способ | Что указать |
|
||||
|--------|-------------|
|
||||
| Публичная подписка | `https://<panel>/api/sub/<shortUuid>` в поле «URL подписки» |
|
||||
| Панель + short UUID | блок Remnawave API → URL панели + short UUID → «Синхронизировать» |
|
||||
| С API token | токен из панели → API Tokens (для `/api/subscriptions/…`, `/api/users/…`) |
|
||||
| Caddy auth | опциональный `X-Api-Key` |
|
||||
|
||||
Токены хранятся только локально в `configs/config.json` (не коммитьте).
|
||||
|
||||
## Сборка macOS (кросс с Windows / Linux)
|
||||
|
||||
```bat
|
||||
@@ -270,6 +285,11 @@ https://evilfox.win/
|
||||
|
||||
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
|
||||
|
||||
В 3.9.0:
|
||||
- выдача конфигов через Remnawave API (`/api/sub/{shortUuid}`, опционально Bearer token);
|
||||
- UI в стиле Remnawave (тёмная тема по умолчанию, cyan accent), флаги стран у серверов;
|
||||
- MSIX / Store prep для `3.9.0.1`.
|
||||
|
||||
В 2.9.0:
|
||||
- светлая / тёмная тема с переключателем (preference в localStorage, по умолчанию — системная);
|
||||
- подготовка к Microsoft Store: MSIX-манифест, `scripts/pack-msix.ps1`, отключение in-app updater в MSIX-сборке.
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||
versionCode = 3_080_201
|
||||
versionName = "3.8.2+1"
|
||||
versionCode = 4_024_001
|
||||
versionName = "4.2.4+1"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
|
||||
@@ -94,7 +94,7 @@ fun NavisScreen(
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Navis", fontSize = 28.sp, fontWeight = FontWeight.Black, color = Ink)
|
||||
Text("EvilFox", fontSize = 28.sp, fontWeight = FontWeight.Black, color = Ink)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(color = Soft, shape = RoundedCornerShape(999.dp)) {
|
||||
Text("2.6.1", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Navis</string>
|
||||
<string name="vpn_notification_title">Navis VPN</string>
|
||||
<string name="app_name">EvilFox</string>
|
||||
<string name="vpn_notification_title">EvilFox VPN</string>
|
||||
<string name="vpn_notification_connected">Подключено</string>
|
||||
<string name="vpn_notification_channel">VPN</string>
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="3.8.2.1"
|
||||
version="3.9.0.3"
|
||||
processorArchitecture="*"
|
||||
name="EvilFox.Navis"
|
||||
type="win32"/>
|
||||
|
||||
@@ -14,22 +14,27 @@ if errorlevel 1 (
|
||||
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo Building Navis GUI and CLI...
|
||||
go build -ldflags="-H windowsgui -s -w" -trimpath -o Navis.exe ./cmd/vpnapp
|
||||
echo Building EvilFox GUI and CLI...
|
||||
go build -ldflags="-H windowsgui -s -w" -trimpath -o EvilFox.exe ./cmd/vpnapp
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
go build -ldflags="-s -w" -trimpath -o vpnclient.exe ./cmd/vpnclient
|
||||
go build -ldflags="-s -w" -trimpath -o evilfox-cli.exe ./cmd/vpnclient
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
mkdir "dist\navis-release\windows" 2>nul
|
||||
copy /Y Navis.exe "dist\navis-release\windows\Navis.exe" >nul
|
||||
copy /Y vpnclient.exe "dist\navis-release\windows\vpnclient.exe" >nul
|
||||
copy /Y EvilFox.exe "dist\navis-release\windows\EvilFox.exe" >nul
|
||||
REM Keep Navis.exe path for existing 3.x clients that fetch the old feed URL.
|
||||
copy /Y EvilFox.exe "dist\navis-release\windows\Navis.exe" >nul
|
||||
copy /Y evilfox-cli.exe "dist\navis-release\windows\evilfox-cli.exe" >nul
|
||||
copy /Y evilfox-cli.exe "dist\navis-release\windows\vpnclient.exe" >nul
|
||||
|
||||
echo.
|
||||
echo Done:
|
||||
echo Navis.exe
|
||||
echo vpnclient.exe
|
||||
echo dist\navis-release\windows\Navis.exe
|
||||
echo EvilFox.exe
|
||||
echo evilfox-cli.exe
|
||||
echo dist\navis-release\windows\EvilFox.exe
|
||||
echo dist\navis-release\windows\Navis.exe (compat alias for 3.x updater)
|
||||
echo dist\navis-release\windows\evilfox-cli.exe
|
||||
echo dist\navis-release\windows\vpnclient.exe
|
||||
echo assets\navis.ico
|
||||
endlocal
|
||||
|
||||
@@ -71,7 +71,7 @@ func main() {
|
||||
log.Printf("open UI: %v — откройте вручную: %s", err, uiURL)
|
||||
fmt.Println(uiURL)
|
||||
} else {
|
||||
log.Printf("Navis UI: %s", uiURL)
|
||||
log.Printf("EvilFox UI: %s", uiURL)
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
@@ -96,7 +96,7 @@ func openAppWindow(uiURL string) error {
|
||||
func fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
|
||||
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "EvilFox" message %q`, msg)).Run()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "Navis GUI supports Windows (WebView2) and macOS.")
|
||||
fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update")
|
||||
fmt.Fprintln(os.Stderr, "EvilFox GUI supports Windows (WebView2) and macOS.")
|
||||
fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./evilfox-cli connect | install-core | check-update")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+106
-22
@@ -1,4 +1,4 @@
|
||||
//go:build windows
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/jchv/go-webview2"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"vpnclient/internal/apphost"
|
||||
"vpnclient/internal/appui"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/protocols/xray"
|
||||
"vpnclient/internal/remnawave"
|
||||
"vpnclient/internal/update"
|
||||
)
|
||||
|
||||
@@ -55,8 +57,17 @@ type uiState struct {
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
VPNSupported bool `json:"vpn_mode_supported"`
|
||||
VPNActive bool `json:"vpn_active"`
|
||||
// KillSwitch — persisted setting; KillSwitchActive — tunnel died and
|
||||
// traffic is intentionally blocked until disconnect / reconnect.
|
||||
KillSwitch bool `json:"kill_switch"`
|
||||
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -103,14 +114,15 @@ func main() {
|
||||
|
||||
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
|
||||
|
||||
// Sidebar layout (Hiddify-style) needs a wider window than the old column UI.
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
DataPath: dataPath,
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "Navis 2",
|
||||
Width: 500,
|
||||
Height: 900,
|
||||
Title: "EvilFox",
|
||||
Width: 920,
|
||||
Height: 680,
|
||||
Center: true,
|
||||
IconId: 1,
|
||||
},
|
||||
@@ -125,7 +137,7 @@ func main() {
|
||||
}()
|
||||
|
||||
applyWindowIcon(w.Window())
|
||||
w.SetSize(500, 900, webview2.HintNone)
|
||||
w.SetSize(920, 680, webview2.HintNone)
|
||||
|
||||
mustBind(w, "getState", a.getState)
|
||||
mustBind(w, "connect", a.connect)
|
||||
@@ -138,11 +150,17 @@ func main() {
|
||||
mustBind(w, "installCore", a.installCore)
|
||||
mustBind(w, "openURL", openURL)
|
||||
mustBind(w, "pingServers", a.pingServers)
|
||||
mustBind(w, "pingServer", a.pingServer)
|
||||
mustBind(w, "pingBest", a.pingBest)
|
||||
mustBind(w, "setKillSwitch", a.setKillSwitch)
|
||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
||||
mustBind(w, "saveHy2", a.saveHy2)
|
||||
mustBind(w, "importSubscription", a.importSubscription)
|
||||
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
||||
mustBind(w, "importRemnawave", a.importRemnawave)
|
||||
mustBind(w, "setMode", a.setMode)
|
||||
mustBind(w, "getLogs", a.getLogs)
|
||||
|
||||
go a.autoCheckUpdate()
|
||||
|
||||
@@ -209,8 +227,15 @@ func (a *app) getState() (uiState, error) {
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
SubInfo: a.mgr.SubscriptionInfo(),
|
||||
Remnawave: a.mgr.RemnawaveSettings(),
|
||||
Hy2: a.mgr.ActiveHy2Options(),
|
||||
StorePackaged: update.IsStorePackaged(),
|
||||
Mode: a.mgr.Mode(),
|
||||
VPNSupported: a.mgr.VPNModeSupported(),
|
||||
VPNActive: st.VPNActive,
|
||||
KillSwitch: cfg.KillSwitch,
|
||||
KillSwitchActive: st.KillSwitchActive,
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
@@ -322,10 +347,34 @@ func (a *app) saveHy2(opts core.Hy2Options) error {
|
||||
return a.mgr.SaveHy2Options(opts)
|
||||
}
|
||||
|
||||
func (a *app) importSubscription(rawURL string) (int, error) {
|
||||
func (a *app) importSubscription(rawURL string) (core.ImportResult, error) {
|
||||
return a.mgr.ImportSubscription(rawURL)
|
||||
}
|
||||
|
||||
func (a *app) saveRemnawave(s remnawave.Settings) error {
|
||||
return a.mgr.SaveRemnawaveSettings(s)
|
||||
}
|
||||
|
||||
func (a *app) importRemnawave(s remnawave.Settings) (core.ImportResult, error) {
|
||||
return a.mgr.ImportRemnawave(s)
|
||||
}
|
||||
|
||||
func (a *app) setMode(mode string) error {
|
||||
return a.mgr.SetMode(strings.TrimSpace(mode))
|
||||
}
|
||||
|
||||
func (a *app) getLogs() (string, error) {
|
||||
if a.logBuf == nil {
|
||||
return "", nil
|
||||
}
|
||||
const maxTail = 64 * 1024
|
||||
b := a.logBuf.Bytes()
|
||||
if len(b) > maxTail {
|
||||
b = b[len(b)-maxTail:]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
type pingBestResult struct {
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
BestName string `json:"best_name,omitempty"`
|
||||
@@ -339,6 +388,50 @@ func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// pingServer measures latency of one server (📊 button on a server row)
|
||||
// and merges the result into the cached ping list.
|
||||
func (a *app) pingServer(name string) (netcheck.Result, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
a.mu.Lock()
|
||||
list := a.mgr.Profiles()
|
||||
a.mu.Unlock()
|
||||
var target *netcheck.Target
|
||||
for _, p := range list {
|
||||
if p.Name == name {
|
||||
target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy}
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
out := netcheck.PingAll(ctx, []netcheck.Target{*target})
|
||||
if len(out) == 0 {
|
||||
return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг")
|
||||
}
|
||||
res := out[0]
|
||||
a.mu.Lock()
|
||||
replaced := false
|
||||
for i := range a.pings {
|
||||
if a.pings[i].Name == res.Name {
|
||||
a.pings[i] = res
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
a.pings = append(a.pings, res)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (a *app) setKillSwitch(enabled bool) error {
|
||||
return a.mgr.SetKillSwitch(enabled)
|
||||
}
|
||||
|
||||
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
|
||||
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
|
||||
pings, err := a.runPings()
|
||||
@@ -448,12 +541,12 @@ func (a *app) applyUpdate() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
_ = a.mgr.Disconnect()
|
||||
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
|
||||
// Hard-exit so Windows unlocks EvilFox.exe; do not wait on webview.Terminate (can hang).
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
||||
return "Устанавливается v" + latest + "… EvilFox перезапустится.", nil
|
||||
}
|
||||
|
||||
func (a *app) autoCheckUpdate() {
|
||||
@@ -476,21 +569,12 @@ func (a *app) autoCheckUpdate() {
|
||||
}
|
||||
|
||||
func openURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("пустая ссылка")
|
||||
// Shared allowlist: shop + «Полезные ссылки» hosts only.
|
||||
normalized, err := apphost.LinkAllowed(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
||||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
||||
raw = "https://evilfox.win/"
|
||||
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
||||
// allow shop deep-links on evilfox.win only
|
||||
default:
|
||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
||||
}
|
||||
return shellOpen(raw)
|
||||
return shellOpen(normalized)
|
||||
}
|
||||
|
||||
func shellOpen(raw string) error {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func messageBox(text string) {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
proc := user32.NewProc("MessageBoxW")
|
||||
title, _ := syscall.UTF16PtrFromString("Navis")
|
||||
title, _ := syscall.UTF16PtrFromString("EvilFox")
|
||||
body, _ := syscall.UTF16PtrFromString(text)
|
||||
proc.Call(0, uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(title)), 0x10) // MB_ICONERROR
|
||||
}
|
||||
|
||||
@@ -59,16 +59,16 @@ func main() {
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `Navis VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan) — Windows / macOS
|
||||
fmt.Fprintf(os.Stderr, `EvilFox VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan) — Windows / macOS
|
||||
|
||||
Usage:
|
||||
navis init [-config configs/config.json]
|
||||
navis install-core [-config configs/config.json]
|
||||
navis set-proxy [-config configs/config.json] <share-or-proxy-uri>
|
||||
navis connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe]
|
||||
navis probe [-config configs/config.json] [-profile name] [-url URL]
|
||||
navis check-update
|
||||
navis apply-update
|
||||
evilfox-cli init [-config configs/config.json]
|
||||
evilfox-cli install-core [-config configs/config.json]
|
||||
evilfox-cli set-proxy [-config configs/config.json] <share-or-proxy-uri>
|
||||
evilfox-cli connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe]
|
||||
evilfox-cli probe [-config configs/config.json] [-profile name] [-url URL]
|
||||
evilfox-cli check-update
|
||||
evilfox-cli apply-update
|
||||
|
||||
Share links: naive+https://… hysteria2://… vless://… vmess://… trojan://… awg .conf
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"system_proxy": true,
|
||||
"bin_dir": "bin",
|
||||
"subscription_url": "",
|
||||
"remnawave_base_url": "",
|
||||
"remnawave_short_uuid": "",
|
||||
"remnawave_token": "",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "naive-main",
|
||||
|
||||
Vendored
+35
-35
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "3.8.2: Windows product version 3.8.2.1 (build 1).",
|
||||
"version": "4.2.4",
|
||||
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"android": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/android/Navis.apk",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/android/Navis.apk",
|
||||
"sha256": "4416770bebe576eba42811990c4e4424f6722dea3a8513e6164dd34fc975b3cf",
|
||||
"os": "android",
|
||||
"arch": "universal"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+35
-35
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "3.8.2: Windows product version 3.8.2.1 (build 1).",
|
||||
"version": "4.2.4",
|
||||
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"android": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/android/Navis.apk",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/android/Navis.apk",
|
||||
"sha256": "4416770bebe576eba42811990c4e4424f6722dea3a8513e6164dd34fc975b3cf",
|
||||
"os": "android",
|
||||
"arch": "universal"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
module vpnclient
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/amnezia-vpn/amneziawg-go v0.2.19
|
||||
github.com/diskfs/go-diskfs v1.9.4
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
||||
github.com/xjasonlyu/tun2socks/v2 v2.7.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/sys v0.46.0
|
||||
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,9 +24,13 @@ require (
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3Aaf
|
||||
github.com/amnezia-vpn/amneziawg-go v0.2.19/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
|
||||
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
@@ -32,29 +32,43 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xjasonlyu/tun2socks/v2 v2.7.0 h1:fYEN0Q1sSanuoID8xvUTsMODbKLJJH/J5ywYoMRxIPw=
|
||||
github.com/xjasonlyu/tun2socks/v2 v2.7.0/go.mod h1:XHgJ3wbs63mZxGbJawt6jjNX65aPDl+Qhn2ePPEE4KI=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
|
||||
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
|
||||
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96 h1:LZXOf4NwvAwUy/eI8P+Y3DUdYLyaCEDNPEjsL2OP2ro=
|
||||
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY=
|
||||
|
||||
+139
-13
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/protocols/xray"
|
||||
"vpnclient/internal/remnawave"
|
||||
"vpnclient/internal/update"
|
||||
)
|
||||
|
||||
@@ -53,8 +55,17 @@ type UIState struct {
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
VPNSupported bool `json:"vpn_mode_supported"`
|
||||
VPNActive bool `json:"vpn_active"`
|
||||
// KillSwitch is the persisted setting; KillSwitchActive means the tunnel
|
||||
// died unexpectedly and traffic is blocked until disconnect/reconnect.
|
||||
KillSwitch bool `json:"kill_switch"`
|
||||
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
|
||||
}
|
||||
|
||||
type PingBestResult struct {
|
||||
@@ -130,8 +141,15 @@ func (a *App) GetState() (UIState, error) {
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
SubInfo: a.Mgr.SubscriptionInfo(),
|
||||
Remnawave: a.Mgr.RemnawaveSettings(),
|
||||
Hy2: a.Mgr.ActiveHy2Options(),
|
||||
StorePackaged: update.IsStorePackaged(),
|
||||
Mode: a.Mgr.Mode(),
|
||||
VPNSupported: a.Mgr.VPNModeSupported(),
|
||||
VPNActive: st.VPNActive,
|
||||
KillSwitch: cfg.KillSwitch,
|
||||
KillSwitchActive: st.KillSwitchActive,
|
||||
}
|
||||
if out.Protocol == "" {
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
@@ -236,14 +254,84 @@ func (a *App) SaveHy2(opts core.Hy2Options) error {
|
||||
return a.Mgr.SaveHy2Options(opts)
|
||||
}
|
||||
|
||||
func (a *App) ImportSubscription(rawURL string) (int, error) {
|
||||
func (a *App) ImportSubscription(rawURL string) (core.ImportResult, error) {
|
||||
return a.Mgr.ImportSubscription(rawURL)
|
||||
}
|
||||
|
||||
func (a *App) SaveRemnawave(s remnawave.Settings) error {
|
||||
return a.Mgr.SaveRemnawaveSettings(s)
|
||||
}
|
||||
|
||||
func (a *App) ImportRemnawave(s remnawave.Settings) (core.ImportResult, error) {
|
||||
return a.Mgr.ImportRemnawave(s)
|
||||
}
|
||||
|
||||
func (a *App) SetMode(mode string) error {
|
||||
return a.Mgr.SetMode(strings.TrimSpace(mode))
|
||||
}
|
||||
|
||||
// GetLogs returns the tail of the core log buffer for the «Логи» page.
|
||||
func (a *App) GetLogs() (string, error) {
|
||||
if a.LogBuf == nil {
|
||||
return "", nil
|
||||
}
|
||||
const maxTail = 64 * 1024
|
||||
b := a.LogBuf.Bytes()
|
||||
if len(b) > maxTail {
|
||||
b = b[len(b)-maxTail:]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (a *App) PingServers() ([]netcheck.Result, error) {
|
||||
return a.runPings()
|
||||
}
|
||||
|
||||
// PingServer measures latency of a single server (📊 button on a server row)
|
||||
// and merges the result into the cached ping list.
|
||||
func (a *App) PingServer(name string) (netcheck.Result, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
a.mu.Lock()
|
||||
list := a.Mgr.Profiles()
|
||||
a.mu.Unlock()
|
||||
var target *netcheck.Target
|
||||
for _, p := range list {
|
||||
if p.Name == name {
|
||||
target = &netcheck.Target{Name: p.Name, Protocol: config.Protocol(p.Protocol), Proxy: p.Proxy}
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return netcheck.Result{}, fmt.Errorf("сервер %q не найден", name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
out := netcheck.PingAll(ctx, []netcheck.Target{*target})
|
||||
if len(out) == 0 {
|
||||
return netcheck.Result{}, fmt.Errorf("не удалось измерить пинг")
|
||||
}
|
||||
res := out[0]
|
||||
a.mu.Lock()
|
||||
replaced := false
|
||||
for i := range a.Pings {
|
||||
if a.Pings[i].Name == res.Name {
|
||||
a.Pings[i] = res
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
a.Pings = append(a.Pings, res)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetKillSwitch persists the leak-protection setting.
|
||||
func (a *App) SetKillSwitch(enabled bool) error {
|
||||
return a.Mgr.SetKillSwitch(enabled)
|
||||
}
|
||||
|
||||
func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
||||
pings, err := a.runPings()
|
||||
out := PingBestResult{Pings: pings}
|
||||
@@ -360,7 +448,7 @@ func (a *App) ApplyUpdate() (string, error) {
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
||||
return "Устанавливается v" + latest + "… EvilFox перезапустится.", nil
|
||||
}
|
||||
|
||||
func (a *App) AutoCheckUpdate() {
|
||||
@@ -381,22 +469,40 @@ func (a *App) AutoCheckUpdate() {
|
||||
_, _ = a.CheckUpdate()
|
||||
}
|
||||
|
||||
func (a *App) OpenShopURL(raw string) error {
|
||||
// allowedLinkHosts are the only hosts the UI may open in the default browser
|
||||
// (shop / «Полезные ссылки» in index.html).
|
||||
var allowedLinkHosts = map[string]bool{
|
||||
"evilfox.win": true,
|
||||
"t.me": true,
|
||||
}
|
||||
|
||||
// LinkAllowed validates that raw is an http(s) URL pointing at one of the
|
||||
// allowed hosts; returns the normalized URL.
|
||||
func LinkAllowed(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("пустая ссылка")
|
||||
return "", fmt.Errorf("пустая ссылка")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
||||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
||||
raw = "https://evilfox.win/"
|
||||
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
||||
default:
|
||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("некорректная ссылка")
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return "", fmt.Errorf("разрешены только http(s) ссылки")
|
||||
}
|
||||
if !allowedLinkHosts[strings.ToLower(u.Hostname())] {
|
||||
return "", fmt.Errorf("ссылка на %s не разрешена", u.Hostname())
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (a *App) OpenShopURL(raw string) error {
|
||||
normalized, err := LinkAllowed(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.OpenURL != nil {
|
||||
return a.OpenURL(raw)
|
||||
return a.OpenURL(normalized)
|
||||
}
|
||||
return fmt.Errorf("openURL не настроен")
|
||||
}
|
||||
@@ -472,6 +578,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||
return nil, a.OpenShopURL(arg(args, 0, ""))
|
||||
case "pingServers":
|
||||
return a.PingServers()
|
||||
case "pingServer":
|
||||
return a.PingServer(arg(args, 0, ""))
|
||||
case "setKillSwitch":
|
||||
return nil, a.SetKillSwitch(arg(args, 0, false))
|
||||
case "pingBest":
|
||||
return a.PingBest(arg(args, 0, false))
|
||||
case "checkUpdate":
|
||||
@@ -486,6 +596,22 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
||||
return nil, a.SaveHy2(opts)
|
||||
case "importSubscription":
|
||||
return a.ImportSubscription(arg(args, 0, ""))
|
||||
case "saveRemnawave":
|
||||
var s remnawave.Settings
|
||||
if len(args) > 0 {
|
||||
_ = json.Unmarshal(args[0], &s)
|
||||
}
|
||||
return nil, a.SaveRemnawave(s)
|
||||
case "importRemnawave":
|
||||
var s remnawave.Settings
|
||||
if len(args) > 0 {
|
||||
_ = json.Unmarshal(args[0], &s)
|
||||
}
|
||||
return a.ImportRemnawave(s)
|
||||
case "setMode":
|
||||
return nil, a.SetMode(arg(args, 0, ""))
|
||||
case "getLogs":
|
||||
return a.GetLogs()
|
||||
case "quit":
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
+2197
-490
File diff suppressed because it is too large
Load Diff
@@ -27,15 +27,51 @@ type Config struct {
|
||||
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
|
||||
// Mode selects traffic capture: "proxy" (default — local proxy + optional
|
||||
// system proxy) or "vpn" (Windows: TUN device, ALL traffic incl. apps/games).
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// KillSwitch keeps leak protection engaged when the tunnel dies
|
||||
// unexpectedly: in VPN mode the TUN blackhole routes stay up, in proxy
|
||||
// mode the system proxy keeps pointing at the dead local port, so traffic
|
||||
// is blocked until the user disconnects manually or reconnects.
|
||||
KillSwitch bool `json:"kill_switch,omitempty"`
|
||||
|
||||
// BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
|
||||
BinDir string `json:"bin_dir,omitempty"`
|
||||
|
||||
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
|
||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
||||
|
||||
// Remnawave panel integration (local only — do not commit tokens).
|
||||
RemnawaveBaseURL string `json:"remnawave_base_url,omitempty"`
|
||||
RemnawaveToken string `json:"remnawave_token,omitempty"`
|
||||
RemnawaveShortUUID string `json:"remnawave_short_uuid,omitempty"`
|
||||
RemnawaveCaddyToken string `json:"remnawave_caddy_token,omitempty"`
|
||||
|
||||
// SubInfo caches last-known subscription usage so the UI can render it
|
||||
// after restart; refreshed on subscription import.
|
||||
SubInfo *SubscriptionInfo `json:"subscription_info,omitempty"`
|
||||
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
// SubscriptionInfo is last-known subscription usage metadata.
|
||||
// Traffic values are bytes; Total==0 means unlimited/unknown.
|
||||
type SubscriptionInfo struct {
|
||||
Upload int64 `json:"upload,omitempty"`
|
||||
Download int64 `json:"download,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Expire int64 `json:"expire,omitempty"` // unix seconds; 0 = unknown
|
||||
|
||||
// Devices are known only when the Remnawave API token is configured.
|
||||
DevicesKnown bool `json:"devices_known,omitempty"`
|
||||
Devices int `json:"devices,omitempty"`
|
||||
DeviceLimit int `json:"device_limit,omitempty"` // 0 = no limit / unknown
|
||||
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // unix seconds of last refresh
|
||||
}
|
||||
|
||||
// Profile describes one connection endpoint.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
@@ -171,6 +207,11 @@ func stripUTF8BOM(data []byte) []byte {
|
||||
|
||||
// Validate checks required fields.
|
||||
func (c *Config) Validate() error {
|
||||
switch c.Mode {
|
||||
case "", "proxy", "vpn":
|
||||
default:
|
||||
c.Mode = "proxy"
|
||||
}
|
||||
if len(c.Profiles) == 0 {
|
||||
return fmt.Errorf("config: no profiles defined")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/geoflag"
|
||||
)
|
||||
|
||||
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
|
||||
@@ -12,18 +14,26 @@ type ProfileInfo struct {
|
||||
Proxy string `json:"proxy"`
|
||||
Host string `json:"host"`
|
||||
Active bool `json:"active"`
|
||||
Country string `json:"country,omitempty"` // ISO 3166-1 alpha-2
|
||||
Flag string `json:"flag,omitempty"` // emoji flag or empty
|
||||
}
|
||||
|
||||
// ListProfiles returns UI-friendly profile summaries.
|
||||
func (c *Config) ListProfiles() []ProfileInfo {
|
||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||
for _, p := range c.Profiles {
|
||||
cc, flag := geoflag.FromText(p.Name)
|
||||
if cc == "" {
|
||||
cc, flag = geoflag.FromText(proxyHost(p.Proxy))
|
||||
}
|
||||
out = append(out, ProfileInfo{
|
||||
Name: p.Name,
|
||||
Protocol: string(p.Protocol),
|
||||
Proxy: p.Proxy,
|
||||
Host: proxyHost(p.Proxy),
|
||||
Active: p.Name == c.Active,
|
||||
Country: cc,
|
||||
Flag: flag,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -24,5 +24,9 @@ type Status struct {
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
VPNActive bool `json:"vpn_active,omitempty"`
|
||||
// KillSwitchActive: the tunnel died unexpectedly and traffic is being
|
||||
// blocked on purpose until manual disconnect / reconnect.
|
||||
KillSwitchActive bool `json:"kill_switch_active,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
+349
-12
@@ -15,12 +15,15 @@ import (
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/linknorm"
|
||||
"vpnclient/internal/netcheck"
|
||||
"vpnclient/internal/protocols/awg"
|
||||
"vpnclient/internal/protocols/hysteria2"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/protocols/xray"
|
||||
"vpnclient/internal/remnawave"
|
||||
"vpnclient/internal/subscription"
|
||||
"vpnclient/internal/sysproxy"
|
||||
"vpnclient/internal/vpnmode"
|
||||
)
|
||||
|
||||
// Manager orchestrates protocol engines and Windows system proxy.
|
||||
@@ -33,6 +36,15 @@ type Manager struct {
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
|
||||
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
|
||||
vpn *vpnmode.Session
|
||||
|
||||
// watchGen invalidates engine watchdogs from previous connects.
|
||||
watchGen uint64
|
||||
// ksEngaged is true after an unexpected engine death with kill switch on:
|
||||
// blackhole routes / dead system proxy are intentionally left in place.
|
||||
ksEngaged bool
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
@@ -60,6 +72,18 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
|
||||
// Reconnect releases a previously engaged kill switch (blackhole state).
|
||||
if m.ksEngaged {
|
||||
if m.vpn != nil {
|
||||
_ = m.vpn.Close()
|
||||
m.vpn = nil
|
||||
}
|
||||
if m.sys.Enabled() {
|
||||
_ = m.sys.Disable()
|
||||
}
|
||||
m.ksEngaged = false
|
||||
}
|
||||
|
||||
if profileName != "" {
|
||||
m.cfg.Active = profileName
|
||||
}
|
||||
@@ -77,7 +101,37 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.cfg.SystemProxy {
|
||||
vpnActive := false
|
||||
if m.cfg.Mode == "vpn" {
|
||||
if !vpnmode.Supported() {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("режим VPN пока доступен только на Windows — переключитесь на режим прокси")
|
||||
}
|
||||
socksAddr, ok := engine.LocalSOCKSProxy()
|
||||
if !ok {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("режим VPN: у протокола нет локального SOCKS-прокси")
|
||||
}
|
||||
host, _, err := netcheck.HostPort(profile.Protocol, profile.Proxy)
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("режим VPN: не удалось определить адрес сервера для исключения из туннеля")
|
||||
}
|
||||
sess, err := vpnmode.Start(vpnmode.Config{
|
||||
SOCKSAddr: socksAddr,
|
||||
ExcludeHosts: []string{host},
|
||||
Stderr: m.stderr,
|
||||
})
|
||||
if err != nil {
|
||||
_ = engine.Stop()
|
||||
return err
|
||||
}
|
||||
m.vpn = sess
|
||||
vpnActive = true
|
||||
}
|
||||
|
||||
// System proxy is redundant while the TUN captures everything.
|
||||
if m.cfg.SystemProxy && !vpnActive {
|
||||
httpProxy, ok := engine.LocalHTTPProxy()
|
||||
if !ok {
|
||||
_ = engine.Stop()
|
||||
@@ -88,6 +142,10 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
|
||||
} else {
|
||||
_ = engine.Stop()
|
||||
if m.vpn != nil {
|
||||
_ = m.vpn.Close()
|
||||
m.vpn = nil
|
||||
}
|
||||
return fmt.Errorf("enable system proxy: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -95,18 +153,88 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
|
||||
m.engine = engine
|
||||
m.profile = profile
|
||||
m.watchGen++
|
||||
go m.watchEngine(m.watchGen, engine)
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchEngine polls the engine and reacts to an UNEXPECTED death (crash of the
|
||||
// core process). A normal Disconnect bumps watchGen first, so the watcher just
|
||||
// exits. With kill switch enabled the blackhole state is kept, otherwise the
|
||||
// network is restored (system proxy off, TUN routes removed).
|
||||
func (m *Manager) watchEngine(gen uint64, engine Engine) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.mu.Lock()
|
||||
if m.watchGen != gen || m.engine != engine {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if engine.Running() {
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
// Unexpected death: engine stopped without Disconnect.
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
ks := m.cfg.KillSwitch
|
||||
vpn := m.vpn
|
||||
sysOn := m.sys.Enabled()
|
||||
if ks && (vpn != nil || sysOn) {
|
||||
// Keep TUN blackhole routes / dead system proxy: traffic is
|
||||
// blocked until the user disconnects manually or reconnects.
|
||||
m.ksEngaged = true
|
||||
m.mu.Unlock()
|
||||
fmt.Fprintf(m.stderr, "kill switch: соединение прервано — трафик заблокирован до ручного отключения или переподключения\n")
|
||||
return
|
||||
}
|
||||
m.vpn = nil
|
||||
m.mu.Unlock()
|
||||
if vpn != nil {
|
||||
_ = vpn.Close()
|
||||
}
|
||||
if sysOn {
|
||||
_ = m.sys.Disable()
|
||||
}
|
||||
fmt.Fprintf(m.stderr, "соединение прервано — сетевые настройки восстановлены\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// KillSwitchEnabled returns the persisted kill switch setting.
|
||||
func (m *Manager) KillSwitchEnabled() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg.KillSwitch
|
||||
}
|
||||
|
||||
// SetKillSwitch persists the kill switch setting.
|
||||
func (m *Manager) SetKillSwitch(enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg.KillSwitch = enabled
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) Disconnect() error {
|
||||
m.mu.Lock()
|
||||
sys := m.sys
|
||||
engine := m.engine
|
||||
vpn := m.vpn
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.vpn = nil
|
||||
m.watchGen++ // cancel the engine watchdog — this stop is intentional
|
||||
m.ksEngaged = false
|
||||
m.mu.Unlock()
|
||||
|
||||
var first error
|
||||
if vpn != nil {
|
||||
if err := vpn.Close(); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
if sys != nil && sys.Enabled() {
|
||||
if err := sys.Disable(); err != nil && first == nil {
|
||||
first = err
|
||||
@@ -134,7 +262,7 @@ func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
st := Status{SystemProxy: m.sys.Enabled()}
|
||||
st := Status{SystemProxy: m.sys.Enabled(), VPNActive: m.vpn != nil, KillSwitchActive: m.ksEngaged}
|
||||
if m.engine == nil || !m.engine.Running() {
|
||||
return st
|
||||
}
|
||||
@@ -219,7 +347,54 @@ func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
m.cfg.SystemProxy = enabled
|
||||
}
|
||||
|
||||
// Mode returns the persisted traffic mode: "proxy" (default) or "vpn".
|
||||
func (m *Manager) Mode() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg.Mode == "vpn" {
|
||||
return "vpn"
|
||||
}
|
||||
return "proxy"
|
||||
}
|
||||
|
||||
// SetMode persists the traffic mode; takes effect on next connect.
|
||||
func (m *Manager) SetMode(mode string) error {
|
||||
switch mode {
|
||||
case "proxy", "vpn":
|
||||
default:
|
||||
return fmt.Errorf("неизвестный режим %q", mode)
|
||||
}
|
||||
if mode == "vpn" {
|
||||
if !vpnmode.Supported() {
|
||||
return fmt.Errorf("режим VPN пока доступен только на Windows")
|
||||
}
|
||||
if !vpnmode.IsElevated() {
|
||||
return fmt.Errorf("Запустите EvilFox от имени администратора для режима VPN")
|
||||
}
|
||||
if !vpnmode.WintunAvailable() {
|
||||
return fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с EvilFox.exe")
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь, затем меняйте режим")
|
||||
}
|
||||
m.cfg.Mode = mode
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
// VPNModeSupported reports whether the VPN (TUN) switch should be shown.
|
||||
func (m *Manager) VPNModeSupported() bool { return vpnmode.Supported() }
|
||||
|
||||
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||
// A plain http(s) URL without credentials is a subscription URL, not a
|
||||
// proxy link — run the subscription import instead of failing with
|
||||
// "proxy URI missing username".
|
||||
if linknorm.LooksLikeSubscriptionURL(proxy) {
|
||||
_, err := m.ImportSubscription(proxy)
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
active, err := m.cfg.ActiveProfile()
|
||||
@@ -252,6 +427,11 @@ func (m *Manager) SetActiveProfile(name string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
// Subscription URL pasted into the "add server link" field → import it.
|
||||
if linknorm.LooksLikeSubscriptionURL(proxy) {
|
||||
_, err := m.ImportSubscription(proxy)
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
@@ -278,6 +458,11 @@ func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
|
||||
// SaveActiveProfile updates the current profile (rename if needed) and proxy URI.
|
||||
func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
||||
// Subscription URL pasted into the profile link field → import it.
|
||||
if linknorm.LooksLikeSubscriptionURL(proxy) {
|
||||
_, err := m.ImportSubscription(proxy)
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
@@ -417,21 +602,150 @@ func (m *Manager) SubscriptionURL() string {
|
||||
return m.cfg.SubscriptionURL
|
||||
}
|
||||
|
||||
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
||||
func (m *Manager) RemnawaveSettings() remnawave.Settings {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return remnawave.Settings{
|
||||
BaseURL: m.cfg.RemnawaveBaseURL,
|
||||
Token: m.cfg.RemnawaveToken,
|
||||
ShortUUID: m.cfg.RemnawaveShortUUID,
|
||||
CaddyToken: m.cfg.RemnawaveCaddyToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(s.BaseURL)
|
||||
m.cfg.RemnawaveToken = strings.TrimSpace(s.Token)
|
||||
m.cfg.RemnawaveShortUUID = strings.TrimSpace(s.ShortUUID)
|
||||
m.cfg.RemnawaveCaddyToken = strings.TrimSpace(s.CaddyToken)
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
// ImportResult reports how a subscription import went.
|
||||
type ImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Manager) ImportSubscription(rawURL string) (ImportResult, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
items, err := subscription.Fetch(ctx, rawURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
// Remnawave-style URL → persist panel coords and fetch via API helper.
|
||||
if base, short, ok := remnawave.ParseInput(rawURL); ok {
|
||||
s := remnawave.Settings{
|
||||
BaseURL: base,
|
||||
ShortUUID: short,
|
||||
Token: m.RemnawaveSettings().Token,
|
||||
CaddyToken: m.RemnawaveSettings().CaddyToken,
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||
res, used, err := remnawave.Fetch(ctx, s)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return m.applySubscription(ctx, res, used, &s)
|
||||
}
|
||||
|
||||
res, err := subscription.Fetch(ctx, rawURL)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return m.applySubscription(ctx, res, rawURL, nil)
|
||||
}
|
||||
|
||||
// ImportRemnawave fetches configs using saved / provided panel settings.
|
||||
func (m *Manager) ImportRemnawave(s remnawave.Settings) (ImportResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.ShortUUID) == "" {
|
||||
cur := m.RemnawaveSettings()
|
||||
if strings.TrimSpace(s.BaseURL) == "" {
|
||||
s.BaseURL = cur.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(s.ShortUUID) == "" {
|
||||
s.ShortUUID = cur.ShortUUID
|
||||
}
|
||||
if strings.TrimSpace(s.Token) == "" {
|
||||
s.Token = cur.Token
|
||||
}
|
||||
if strings.TrimSpace(s.CaddyToken) == "" {
|
||||
s.CaddyToken = cur.CaddyToken
|
||||
}
|
||||
}
|
||||
res, used, err := remnawave.Fetch(ctx, s)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return m.applySubscription(ctx, res, used, &s)
|
||||
}
|
||||
|
||||
func (m *Manager) applySubscription(ctx context.Context, res *subscription.Result, usedURL string, rw *remnawave.Settings) (ImportResult, error) {
|
||||
if res == nil || len(res.Items) == 0 {
|
||||
if res != nil && res.Skipped > 0 {
|
||||
return ImportResult{Skipped: res.Skipped, Warnings: res.Warnings},
|
||||
fmt.Errorf("нет валидных серверов: %d записей пропущено (%s)", res.Skipped, strings.Join(res.Warnings, "; "))
|
||||
}
|
||||
return ImportResult{}, fmt.Errorf("в конфигурации нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||
}
|
||||
|
||||
// Subscription usage info: header data first, enriched via Remnawave API
|
||||
// (devices / limits) when an API token is configured. Best-effort.
|
||||
subInfo := buildSubInfo(res.Info)
|
||||
{
|
||||
s := m.RemnawaveSettings()
|
||||
if rw != nil {
|
||||
s = *rw
|
||||
if strings.TrimSpace(s.Token) == "" {
|
||||
s.Token = m.RemnawaveSettings().Token
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(s.Token) != "" && strings.TrimSpace(s.BaseURL) != "" && strings.TrimSpace(s.ShortUUID) != "" {
|
||||
if ui, err := remnawave.FetchUserInfo(ctx, s); err == nil && ui != nil {
|
||||
if subInfo == nil {
|
||||
subInfo = &config.SubscriptionInfo{}
|
||||
}
|
||||
if subInfo.Upload == 0 && subInfo.Download == 0 && ui.UsedTraffic > 0 {
|
||||
subInfo.Download = ui.UsedTraffic
|
||||
}
|
||||
if subInfo.Total == 0 && ui.TrafficLimit > 0 {
|
||||
subInfo.Total = ui.TrafficLimit
|
||||
}
|
||||
if subInfo.Expire == 0 && ui.ExpireAt > 0 {
|
||||
subInfo.Expire = ui.ExpireAt
|
||||
}
|
||||
subInfo.DevicesKnown = ui.DevicesKnown
|
||||
subInfo.Devices = ui.Devices
|
||||
subInfo.DeviceLimit = ui.DeviceLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
if subInfo != nil {
|
||||
subInfo.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
|
||||
for _, it := range items {
|
||||
if usedURL != "" {
|
||||
m.cfg.SubscriptionURL = strings.TrimSpace(usedURL)
|
||||
}
|
||||
if rw != nil {
|
||||
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(rw.BaseURL)
|
||||
m.cfg.RemnawaveShortUUID = strings.TrimSpace(rw.ShortUUID)
|
||||
if t := strings.TrimSpace(rw.Token); t != "" {
|
||||
m.cfg.RemnawaveToken = t
|
||||
}
|
||||
if t := strings.TrimSpace(rw.CaddyToken); t != "" {
|
||||
m.cfg.RemnawaveCaddyToken = t
|
||||
}
|
||||
}
|
||||
if subInfo != nil {
|
||||
m.cfg.SubInfo = subInfo
|
||||
}
|
||||
for _, it := range res.Items {
|
||||
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
|
||||
for i := range m.cfg.Profiles {
|
||||
if m.cfg.Profiles[i].Name == it.Name {
|
||||
@@ -441,9 +755,32 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
||||
}
|
||||
}
|
||||
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
|
||||
return 0, err
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return len(items), nil
|
||||
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
|
||||
}
|
||||
|
||||
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return &config.SubscriptionInfo{
|
||||
Upload: info.Upload,
|
||||
Download: info.Download,
|
||||
Total: info.Total,
|
||||
Expire: info.Expire,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionInfo returns the cached subscription usage info (may be nil).
|
||||
func (m *Manager) SubscriptionInfo() *config.SubscriptionInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg.SubInfo == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *m.cfg.SubInfo
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (m *Manager) SaveConfig() error {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
func newTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "configs", "config.json")
|
||||
cfg := config.Example()
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := NewManager(cfgPath, loaded, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const testSubBody = "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@de.example.com:443?encryption=none&type=tcp&security=tls&sni=de.example.com#DE-1\n" +
|
||||
"hysteria2://pass@nl.example.com:9000/?sni=nl.example.com#NL-1\n"
|
||||
|
||||
// A credential-less https URL pasted into the "add server link" field must be
|
||||
// treated as a subscription URL and imported — never fail with
|
||||
// "proxy URI missing username" (regression for the naive proxy-URI parser).
|
||||
func TestSaveProfileRoutesSubscriptionURL(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, testSubBody)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveProfile("whatever", ts.URL+"/ZKszcVcC3xbWb8qj"); err != nil {
|
||||
t.Fatalf("SaveProfile(subscription URL) failed: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range m.Profiles() {
|
||||
names[p.Name] = true
|
||||
}
|
||||
if !names["DE-1"] || !names["NL-1"] {
|
||||
t.Fatalf("subscription profiles not imported, got %v", names)
|
||||
}
|
||||
if m.Config().SubscriptionURL == "" {
|
||||
t.Fatal("subscription URL not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveActiveProfileRoutesSubscriptionURL(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, testSubBody)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveActiveProfile("naive-main", ts.URL+"/short"); err != nil {
|
||||
t.Fatalf("SaveActiveProfile(subscription URL) failed: %v", err)
|
||||
}
|
||||
if len(m.Profiles()) < 3 { // naive-main + 2 imported
|
||||
t.Fatalf("expected imported profiles, got %d", len(m.Profiles()))
|
||||
}
|
||||
}
|
||||
|
||||
// A real naive share link (with credentials) must still be saved as a proxy.
|
||||
func TestSaveProfileKeepsNaiveLink(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
if err := m.SaveProfile("naive-1", "https://user:pass@proxy.example.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := m.Config().ActiveProfile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Protocol != config.ProtocolNaive || !strings.Contains(p.Proxy, "user:pass@") {
|
||||
t.Fatalf("naive profile broken: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Package geoflag maps server/profile names to ISO 3166-1 alpha-2 and emoji flags.
|
||||
package geoflag
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
reBracketCC = regexp.MustCompile(`(?i)[\[(]([a-z]{2})[\])]`)
|
||||
rePrefixCC = regexp.MustCompile(`(?i)^([a-z]{2})(?:\s*[-_|/]\s*|\s+)`)
|
||||
reTokenCC = regexp.MustCompile(`(?i)(?:^|[^a-z])([a-z]{2})(?:\s*[-_|]|$)`)
|
||||
reEmojiFlag = regexp.MustCompile(`[\x{1F1E6}-\x{1F1FF}]{2}`)
|
||||
)
|
||||
|
||||
// FromText returns ISO2 (upper) and emoji flag, or empty if unknown.
|
||||
func FromText(name string) (iso2, emoji string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", ""
|
||||
}
|
||||
if m := reEmojiFlag.FindString(name); m != "" {
|
||||
if cc := emojiToISO(m); cc != "" {
|
||||
return cc, m
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
for _, pair := range countryNames {
|
||||
if strings.Contains(lower, pair.name) {
|
||||
return pair.code, ISOToEmoji(pair.code)
|
||||
}
|
||||
}
|
||||
if m := reBracketCC.FindStringSubmatch(name); len(m) == 2 {
|
||||
cc := strings.ToUpper(m[1])
|
||||
if Known(cc) {
|
||||
return cc, ISOToEmoji(cc)
|
||||
}
|
||||
}
|
||||
if m := rePrefixCC.FindStringSubmatch(name); len(m) == 2 {
|
||||
cc := strings.ToUpper(m[1])
|
||||
if Known(cc) {
|
||||
return cc, ISOToEmoji(cc)
|
||||
}
|
||||
}
|
||||
if m := reTokenCC.FindStringSubmatch(name); len(m) == 2 {
|
||||
cc := strings.ToUpper(m[1])
|
||||
if Known(cc) {
|
||||
return cc, ISOToEmoji(cc)
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Known reports whether cc is a mapped ISO2 code.
|
||||
func Known(cc string) bool {
|
||||
cc = strings.ToUpper(strings.TrimSpace(cc))
|
||||
_, ok := isoSet[cc]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ISOToEmoji converts "DE" → "🇩🇪". Invalid → "".
|
||||
func ISOToEmoji(cc string) string {
|
||||
cc = strings.ToUpper(strings.TrimSpace(cc))
|
||||
if len(cc) != 2 {
|
||||
return ""
|
||||
}
|
||||
a, b := rune(cc[0]), rune(cc[1])
|
||||
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
|
||||
return ""
|
||||
}
|
||||
return string(rune(0x1F1E6+a-'A')) + string(rune(0x1F1E6+b-'A'))
|
||||
}
|
||||
|
||||
func emojiToISO(flag string) string {
|
||||
rs := []rune(flag)
|
||||
if len(rs) != 2 {
|
||||
return ""
|
||||
}
|
||||
a, b := rs[0]-0x1F1E6, rs[1]-0x1F1E6
|
||||
if a < 0 || a > 25 || b < 0 || b > 25 {
|
||||
return ""
|
||||
}
|
||||
cc := string([]byte{byte('A' + a), byte('A' + b)})
|
||||
if !Known(cc) {
|
||||
// Still return regional indicator pair as ISO guess for display.
|
||||
if unicode.IsLetter(rune('A' + a)) {
|
||||
return cc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
type nameCode struct {
|
||||
name string
|
||||
code string
|
||||
}
|
||||
|
||||
// Longer / more specific names first where needed.
|
||||
var countryNames = []nameCode{
|
||||
{"netherlands", "NL"}, {"deutschland", "DE"}, {"germany", "DE"},
|
||||
{"united kingdom", "GB"}, {"great britain", "GB"}, {"england", "GB"},
|
||||
{"united states", "US"}, {"america", "US"}, {"finland", "FI"},
|
||||
{"sweden", "SE"}, {"norway", "NO"}, {"denmark", "DK"}, {"poland", "PL"},
|
||||
{"france", "FR"}, {"spain", "ES"}, {"italy", "IT"}, {"portugal", "PT"},
|
||||
{"switzerland", "CH"}, {"austria", "AT"}, {"belgium", "BE"},
|
||||
{"czech", "CZ"}, {"slovakia", "SK"}, {"hungary", "HU"}, {"romania", "RO"},
|
||||
{"bulgaria", "BG"}, {"ukraine", "UA"}, {"russia", "RU"}, {"turkey", "TR"},
|
||||
{"israel", "IL"}, {"uae", "AE"}, {"emirates", "AE"}, {"dubai", "AE"},
|
||||
{"singapore", "SG"}, {"hong kong", "HK"}, {"japan", "JP"}, {"korea", "KR"},
|
||||
{"taiwan", "TW"}, {"india", "IN"}, {"canada", "CA"}, {"mexico", "MX"},
|
||||
{"brazil", "BR"}, {"argentina", "AR"}, {"australia", "AU"},
|
||||
{"new zealand", "NZ"}, {"latvia", "LV"}, {"lithuania", "LT"},
|
||||
{"estonia", "EE"}, {"moldova", "MD"}, {"georgia", "GE"}, {"armenia", "AM"},
|
||||
{"kazakhstan", "KZ"}, {"uzbekistan", "UZ"}, {"serbia", "RS"},
|
||||
{"croatia", "HR"}, {"slovenia", "SI"}, {"greece", "GR"}, {"cyprus", "CY"},
|
||||
{"ireland", "IE"}, {"iceland", "IS"}, {"luxembourg", "LU"},
|
||||
{"malaysia", "MY"}, {"thailand", "TH"}, {"vietnam", "VN"},
|
||||
{"indonesia", "ID"}, {"philippines", "PH"}, {"south africa", "ZA"},
|
||||
|
||||
// Russian country names (lowercase; longer/more specific first).
|
||||
{"великобритания", "GB"}, {"нидерланды", "NL"}, {"голландия", "NL"},
|
||||
{"германия", "DE"}, {"австралия", "AU"}, {"австрия", "AT"},
|
||||
{"швейцария", "CH"}, {"финляндия", "FI"}, {"швеция", "SE"},
|
||||
{"норвегия", "NO"}, {"дания", "DK"}, {"польша", "PL"},
|
||||
{"франция", "FR"}, {"испания", "ES"}, {"италия", "IT"},
|
||||
{"португалия", "PT"}, {"бельгия", "BE"}, {"чехия", "CZ"},
|
||||
{"словакия", "SK"}, {"словения", "SI"}, {"венгрия", "HU"},
|
||||
{"румыния", "RO"}, {"болгария", "BG"}, {"украина", "UA"},
|
||||
{"россия", "RU"}, {"турция", "TR"}, {"израиль", "IL"},
|
||||
{"эмираты", "AE"}, {"дубай", "AE"}, {"сингапур", "SG"},
|
||||
{"гонконг", "HK"}, {"япония", "JP"}, {"корея", "KR"},
|
||||
{"тайвань", "TW"}, {"индия", "IN"}, {"канада", "CA"},
|
||||
{"бразилия", "BR"}, {"аргентина", "AR"}, {"латвия", "LV"},
|
||||
{"литва", "LT"}, {"эстония", "EE"}, {"молдова", "MD"},
|
||||
{"грузия", "GE"}, {"армения", "AM"}, {"казахстан", "KZ"},
|
||||
{"узбекистан", "UZ"}, {"сербия", "RS"}, {"хорватия", "HR"},
|
||||
{"греция", "GR"}, {"кипр", "CY"}, {"ирландия", "IE"},
|
||||
{"исландия", "IS"}, {"люксембург", "LU"}, {"англия", "GB"},
|
||||
{"сша", "US"}, {"америка", "US"},
|
||||
|
||||
// Частые опечатки в именах серверов на панелях (наблюдались в проде).
|
||||
// Longer stems first (Contains): «румьиния» before «румьиня».
|
||||
{"румьиния", "RO"}, {"румяння", "RO"}, {"румьиня", "RO"},
|
||||
{"румыня", "RO"}, {"руминия", "RO"}, {"румыниа", "RO"},
|
||||
|
||||
// City → country hints (EN + RU).
|
||||
{"frankfurt", "DE"}, {"amsterdam", "NL"}, {"london", "GB"},
|
||||
{"paris", "FR"}, {"helsinki", "FI"}, {"stockholm", "SE"},
|
||||
{"oslo", "NO"}, {"copenhagen", "DK"}, {"warsaw", "PL"},
|
||||
{"prague", "CZ"}, {"vienna", "AT"}, {"zurich", "CH"},
|
||||
{"madrid", "ES"}, {"milan", "IT"}, {"lisbon", "PT"},
|
||||
{"moscow", "RU"}, {"istanbul", "TR"}, {"tokyo", "JP"},
|
||||
{"seoul", "KR"}, {"new york", "US"}, {"los angeles", "US"},
|
||||
{"dallas", "US"}, {"miami", "US"}, {"chicago", "US"},
|
||||
{"toronto", "CA"}, {"riga", "LV"}, {"vilnius", "LT"},
|
||||
{"tallinn", "EE"}, {"bucharest", "RO"}, {"sofia", "BG"},
|
||||
{"kyiv", "UA"}, {"kiev", "UA"}, {"tbilisi", "GE"},
|
||||
{"almaty", "KZ"}, {"франкфурт", "DE"}, {"амстердам", "NL"},
|
||||
{"лондон", "GB"}, {"париж", "FR"}, {"хельсинки", "FI"},
|
||||
{"стокгольм", "SE"}, {"осло", "NO"}, {"копенгаген", "DK"},
|
||||
{"варшава", "PL"}, {"прага", "CZ"}, {"вена", "AT"},
|
||||
{"цюрих", "CH"}, {"мадрид", "ES"}, {"милан", "IT"},
|
||||
{"москва", "RU"}, {"стамбул", "TR"}, {"токио", "JP"},
|
||||
{"сеул", "KR"}, {"торонто", "CA"}, {"рига", "LV"},
|
||||
{"вильнюс", "LT"}, {"таллин", "EE"}, {"бухарест", "RO"},
|
||||
{"софия", "BG"}, {"киев", "UA"}, {"тбилиси", "GE"},
|
||||
}
|
||||
|
||||
var isoSet = map[string]struct{}{
|
||||
"AD": {}, "AE": {}, "AF": {}, "AL": {}, "AM": {}, "AR": {}, "AT": {}, "AU": {},
|
||||
"AZ": {}, "BA": {}, "BD": {}, "BE": {}, "BG": {}, "BH": {}, "BR": {}, "BY": {},
|
||||
"CA": {}, "CH": {}, "CL": {}, "CN": {}, "CO": {}, "CY": {}, "CZ": {}, "DE": {},
|
||||
"DK": {}, "EE": {}, "EG": {}, "ES": {}, "FI": {}, "FR": {}, "GB": {}, "GE": {},
|
||||
"GR": {}, "HK": {}, "HR": {}, "HU": {}, "ID": {}, "IE": {}, "IL": {}, "IN": {},
|
||||
"IQ": {}, "IR": {}, "IS": {}, "IT": {}, "JP": {}, "KG": {}, "KR": {}, "KZ": {},
|
||||
"LT": {}, "LU": {}, "LV": {}, "MD": {}, "MK": {}, "MX": {}, "MY": {}, "NL": {},
|
||||
"NO": {}, "NZ": {}, "PH": {}, "PK": {}, "PL": {}, "PT": {}, "RO": {}, "RS": {},
|
||||
"RU": {}, "SA": {}, "SE": {}, "SG": {}, "SI": {}, "SK": {}, "TH": {}, "TJ": {},
|
||||
"TM": {}, "TR": {}, "TW": {}, "UA": {}, "US": {}, "UZ": {}, "VN": {}, "ZA": {},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package geoflag
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFromText(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, cc string
|
||||
}{
|
||||
{"DE-Frankfurt-1", "DE"},
|
||||
{"[NL] Amsterdam", "NL"},
|
||||
{"🇺🇸 US West", "US"},
|
||||
{"Germany Hetzner", "DE"},
|
||||
{"unknown-node", ""},
|
||||
// Russian country names.
|
||||
{"Германия-1", "DE"},
|
||||
{"Нидерланды Fast", "NL"},
|
||||
{"Сервер Швейцария", "CH"},
|
||||
{"США восток", "US"},
|
||||
{"Великобритания", "GB"},
|
||||
// City → country hints.
|
||||
{"Frankfurt-Hetzner", "DE"},
|
||||
{"Амстердам 2", "NL"},
|
||||
{"node-helsinki", "FI"},
|
||||
// Names from live Remnawave subscription (emoji comes first).
|
||||
{"🇨🇿 Czech Republic Vless", "CZ"},
|
||||
{"🇳🇴 Norway TR+WS", "NO"},
|
||||
{"🇩🇪 Germany 3 HY", "DE"},
|
||||
// Panel-side typos seen in production.
|
||||
{"Румьиня", "RO"},
|
||||
{"Румыня-2", "RO"},
|
||||
{"Румяння", "RO"},
|
||||
{"Румяння — оптимальный", "RO"},
|
||||
{"Румьиния", "RO"},
|
||||
{"Румьиния Fast", "RO"},
|
||||
{"румыниа", "RO"},
|
||||
{"Румыния", "RO"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cc, emoji := FromText(c.in)
|
||||
if cc != c.cc {
|
||||
t.Fatalf("%q: got cc %q want %q", c.in, cc, c.cc)
|
||||
}
|
||||
if c.cc != "" && emoji == "" {
|
||||
t.Fatalf("%q: empty emoji", c.in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package linknorm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
@@ -11,6 +12,23 @@ import (
|
||||
"vpnclient/internal/protocols/xray"
|
||||
)
|
||||
|
||||
// LooksLikeSubscriptionURL reports whether raw is a plain http(s) URL without
|
||||
// userinfo — i.e. a subscription URL rather than a naive proxy share link
|
||||
// (which always carries user:pass@). Callers should route such input into the
|
||||
// subscription import flow instead of the proxy-URI parser.
|
||||
func LooksLikeSubscriptionURL(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
lower := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return u.User == nil
|
||||
}
|
||||
|
||||
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
|
||||
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
@@ -42,6 +60,12 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
|
||||
u, rem, err := awg.NormalizeShareLink(raw)
|
||||
return u, config.ProtocolAWG, rem, err
|
||||
}
|
||||
// Safety net: a bare http(s) URL without user:pass@ is a subscription
|
||||
// URL, not a naive proxy link — callers should have routed it to the
|
||||
// subscription import flow (see LooksLikeSubscriptionURL).
|
||||
if LooksLikeSubscriptionURL(raw) {
|
||||
return "", config.ProtocolNaive, "", fmt.Errorf("это URL-ссылка конфигурации (нет логина и пароля) — вставьте её в поле «Добавить конфигурацию» или сохраните ещё раз: импорт запустится автоматически")
|
||||
}
|
||||
if xray.Detect(raw) {
|
||||
u, rem, err := xray.NormalizeShareLink(raw)
|
||||
return u, xray.DetectProtocol(raw), rem, err
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package linknorm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLooksLikeSubscriptionURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"https://api.evilfox.win/ZKszcVcC3xbWb8qj", true},
|
||||
{"http://panel.example.com/api/sub/abc", true},
|
||||
{" https://api.evilfox.win/ZKszcVcC3xbWb8qj ", true},
|
||||
{"https://user:pass@host:443", false}, // naive proxy link
|
||||
{"https://user@host:443", false}, // has userinfo
|
||||
{"quic://user:pass@host", false},
|
||||
{"vless://uuid@host:443", false},
|
||||
{"naive+https://user:pass@host:443", false},
|
||||
{"not a url", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LooksLikeSubscriptionURL(c.in); got != c.want {
|
||||
t.Fatalf("%q: got %v want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Normalize of a credential-less https URL must never surface the raw
|
||||
// "proxy URI missing username" error — it explains this is a subscription URL.
|
||||
func TestNormalizeSubscriptionURLError(t *testing.T) {
|
||||
_, _, _, err := Normalize("", "https://api.evilfox.win/ZKszcVcC3xbWb8qj")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "missing username") {
|
||||
t.Fatalf("raw parser error leaked: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "конфигурац") {
|
||||
t.Fatalf("error should mention configuration URL: %v", err)
|
||||
}
|
||||
}
|
||||
+30
-36
@@ -98,45 +98,23 @@ func BestOK(results []Result) (Result, bool) {
|
||||
return best, found
|
||||
}
|
||||
|
||||
// PingProfile pings using protocol hints when available.
|
||||
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
|
||||
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
|
||||
res := Result{Name: name}
|
||||
if strings.TrimSpace(proxyURI) == "" {
|
||||
res.Error = "нет ссылки сервера"
|
||||
return res
|
||||
}
|
||||
|
||||
// HostPort extracts the remote server endpoint from a share/proxy URI using
|
||||
// protocol hints. Used for pings and for VPN-mode route exclusions.
|
||||
func HostPort(proto config.Protocol, proxyURI string) (string, string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
switch {
|
||||
case isAWG:
|
||||
return awg.HostPort(proxyURI)
|
||||
case isXray:
|
||||
return xray.HostPort(proxyURI)
|
||||
case hy2:
|
||||
return hysteria2.HostPort(proxyURI)
|
||||
}
|
||||
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 {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
host, port = h, p
|
||||
} else {
|
||||
normalized, _, _, err := linknorm.Normalize(proto, proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
return "", "", err
|
||||
}
|
||||
rest := normalized
|
||||
if i := strings.Index(rest, "://"); i >= 0 {
|
||||
@@ -148,12 +126,29 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
||||
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
host = rest
|
||||
port = "443"
|
||||
host, port := rest, "443"
|
||||
if i := strings.LastIndex(rest, ":"); i >= 0 {
|
||||
host = rest[:i]
|
||||
port = rest[i+1:]
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// PingProfile pings using protocol hints when available.
|
||||
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
|
||||
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
|
||||
res := Result{Name: name}
|
||||
if strings.TrimSpace(proxyURI) == "" {
|
||||
res.Error = "нет ссылки сервера"
|
||||
return res
|
||||
}
|
||||
|
||||
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
|
||||
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
|
||||
host, port, err := HostPort(proto, proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
res.Host = host
|
||||
@@ -164,7 +159,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var err error
|
||||
if hy2 || isAWG {
|
||||
err = probeUDP(ctx, host, port)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package remnawave fetches VPN share links from a Remnawave panel.
|
||||
//
|
||||
// Real panel endpoints (API ≈2.x, see remnawave/backend + rw-sdk):
|
||||
//
|
||||
// GET {base}/api/sub/{shortUuid} — public subscription body (base64/YAML/text)
|
||||
// GET {base}/api/sub/{shortUuid}/{clientType} — typed client subscription
|
||||
// GET {base}/api/subscriptions/by-short-uuid/{shortUuid} — JSON subscription meta (Bearer API token)
|
||||
// GET {base}/api/subscriptions/by-short-uuid/{shortUuid}/raw — rich raw payload (Bearer)
|
||||
// GET {base}/api/users/by-short-uuid/{shortUuid} — user incl. subscriptionUrl (Bearer)
|
||||
//
|
||||
// Auth: Authorization: Bearer <API token> from panel → API Tokens.
|
||||
// Optional: X-Api-Key for Caddy auth frontends.
|
||||
// Responses are often wrapped as {"response": ...}.
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/subscription"
|
||||
)
|
||||
|
||||
// Settings are persisted locally (never committed as secrets).
|
||||
type Settings struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ShortUUID string `json:"short_uuid"`
|
||||
CaddyToken string `json:"caddy_token,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeBase strips trailing slash and optional trailing /api.
|
||||
func NormalizeBase(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, "/")
|
||||
if strings.HasSuffix(strings.ToLower(raw), "/api") {
|
||||
raw = strings.TrimRight(raw[:len(raw)-4], "/")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// PublicSubURL is the standard Remnawave client subscription URL.
|
||||
func PublicSubURL(base, shortUUID string) string {
|
||||
base = NormalizeBase(base)
|
||||
shortUUID = strings.TrimSpace(shortUUID)
|
||||
if base == "" || shortUUID == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/api/sub/" + url.PathEscape(shortUUID)
|
||||
}
|
||||
|
||||
// ParseInput extracts panel base + short UUID from a Remnawave subscription URL.
|
||||
// Accepts:
|
||||
// - https://panel.example.com/api/sub/{short}
|
||||
// - https://panel.example.com/api/sub/{short}/clash
|
||||
// - https://sub.example.com/{short} (custom sub page — returns short only if path is one segment)
|
||||
func ParseInput(raw string) (base, shortUUID string, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", "", false
|
||||
}
|
||||
path := strings.Trim(u.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
// …/api/sub/{short}[/{clientType}]
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if strings.EqualFold(parts[i], "api") && strings.EqualFold(parts[i+1], "sub") && i+2 < len(parts) {
|
||||
short := parts[i+2]
|
||||
if short == "" || strings.EqualFold(short, "outline") {
|
||||
return "", "", false
|
||||
}
|
||||
base = u.Scheme + "://" + u.Host
|
||||
if i > 0 {
|
||||
base += "/" + strings.Join(parts[:i], "/")
|
||||
}
|
||||
return NormalizeBase(base), short, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Fetch downloads configs via Remnawave public sub URL and, if a token is set,
|
||||
// authenticated subscription/user endpoints. Parsed items reuse subscription.ParseBody.
|
||||
func Fetch(ctx context.Context, s Settings) (*subscription.Result, string, error) {
|
||||
s.BaseURL = NormalizeBase(s.BaseURL)
|
||||
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
|
||||
s.Token = strings.TrimSpace(s.Token)
|
||||
s.CaddyToken = strings.TrimSpace(s.CaddyToken)
|
||||
|
||||
if s.BaseURL == "" || s.ShortUUID == "" {
|
||||
return nil, "", fmt.Errorf("укажите URL панели Remnawave и short UUID")
|
||||
}
|
||||
if _, err := url.Parse(s.BaseURL); err != nil {
|
||||
return nil, "", fmt.Errorf("некорректный URL панели")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
pub := PublicSubURL(s.BaseURL, s.ShortUUID)
|
||||
var lastErr error
|
||||
|
||||
// 1) Public subscription (usual end-user path — no token).
|
||||
if res, err := fetchAndParse(ctx, client, s, pub, false); err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, pub, nil
|
||||
} else if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 2) With API token: resolve subscriptionUrl / raw payload.
|
||||
if s.Token != "" {
|
||||
if res, used, err := fetchWithToken(ctx, client, s); err == nil && res != nil && len(res.Items) > 0 {
|
||||
if used == "" {
|
||||
used = pub
|
||||
}
|
||||
return res, used, nil
|
||||
} else if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, "", lastErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа конфигурации)")
|
||||
}
|
||||
|
||||
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) (*subscription.Result, string, error) {
|
||||
base := s.BaseURL
|
||||
short := s.ShortUUID
|
||||
|
||||
// Prefer admin raw endpoint (links in JSON); fall back to meta → subscriptionUrl.
|
||||
candidates := []string{
|
||||
base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short) + "/raw",
|
||||
base + "/api/sub/" + url.PathEscape(short) + "/raw",
|
||||
base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short),
|
||||
base + "/api/users/by-short-uuid/" + url.PathEscape(short),
|
||||
}
|
||||
var lastErr error
|
||||
for _, endpoint := range candidates {
|
||||
body, _, err := doGET(ctx, client, s, endpoint, true)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if res, err := parseRemnawaveBody(body); err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, endpoint, nil
|
||||
}
|
||||
if subURL := extractSubscriptionURL(body); subURL != "" {
|
||||
res, err := subscription.Fetch(ctx, subURL)
|
||||
if err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, subURL, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, "", lastErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги")
|
||||
}
|
||||
|
||||
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (*subscription.Result, error) {
|
||||
body, header, err := doGET(ctx, client, s, endpoint, auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := parseRemnawaveBody(body)
|
||||
if res != nil && res.Info == nil {
|
||||
res.Info = subscription.ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave)")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
if auth && s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
if s.CaddyToken != "" {
|
||||
req.Header.Set("X-Api-Key", s.CaddyToken)
|
||||
}
|
||||
// Remnawave proxy-check middleware may reset plain HTTP / direct panel access
|
||||
// without forwarded headers (see remnawave proxy-check.middleware).
|
||||
if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) {
|
||||
req.Header.Set("X-Forwarded-Proto", u.Scheme)
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Remnawave запрос: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 180 {
|
||||
msg = msg[:180] + "…"
|
||||
}
|
||||
return "", nil, fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return string(raw), resp.Header, nil
|
||||
}
|
||||
|
||||
func shouldSendProxyHeaders(u *url.URL) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "http") {
|
||||
return true
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
|
||||
}
|
||||
|
||||
func parseRemnawaveBody(body string) (*subscription.Result, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустой ответ Remnawave")
|
||||
}
|
||||
// Try JSON envelope / raw DTO first, then classic subscription parse.
|
||||
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
|
||||
if res, err := subscription.ParseBodyDetailed(flattenJSONLinks(body)); err == nil && len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
// Also try parsing the whole JSON as if links were string values only.
|
||||
if flat := extractLinkStrings(body); flat != "" {
|
||||
if res, err := subscription.ParseBodyDetailed(flat); err == nil && len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return subscription.ParseBodyDetailed(body)
|
||||
}
|
||||
|
||||
func flattenJSONLinks(body string) string {
|
||||
links := extractLinkStrings(body)
|
||||
if links != "" {
|
||||
return links
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func extractLinkStrings(body string) string {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
walkJSON(root, &lines)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func walkJSON(v any, out *[]string) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
lower := strings.ToLower(s)
|
||||
if strings.Contains(lower, "://") || strings.HasPrefix(lower, "ss://") {
|
||||
*out = append(*out, s)
|
||||
}
|
||||
case []any:
|
||||
for _, el := range t {
|
||||
walkJSON(el, out)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, el := range t {
|
||||
walkJSON(el, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractSubscriptionURL(body string) string {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
||||
return ""
|
||||
}
|
||||
// Unwrap {"response": ...}
|
||||
if m, ok := root.(map[string]any); ok {
|
||||
if resp, ok := m["response"]; ok {
|
||||
root = resp
|
||||
}
|
||||
}
|
||||
return findURLField(root, []string{
|
||||
"subscriptionUrl", "subscription_url", "subscriptionURL", "url",
|
||||
})
|
||||
}
|
||||
|
||||
func findURLField(v any, keys []string) string {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range keys {
|
||||
for mk, mv := range t {
|
||||
if strings.EqualFold(mk, k) {
|
||||
if s, ok := mv.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(strings.ToLower(s), "http") {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, mv := range t {
|
||||
if u := findURLField(mv, keys); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, el := range t {
|
||||
if u := findURLField(el, keys); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package remnawave
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPublicSubURL(t *testing.T) {
|
||||
got := PublicSubURL("https://panel.example.com/api/", "abc12")
|
||||
want := "https://panel.example.com/api/sub/abc12"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInput(t *testing.T) {
|
||||
base, short, ok := ParseInput("https://vpn.example.com/api/sub/xyz789/clash")
|
||||
if !ok || base != "https://vpn.example.com" || short != "xyz789" {
|
||||
t.Fatalf("got base=%q short=%q ok=%v", base, short, ok)
|
||||
}
|
||||
_, _, ok = ParseInput("https://example.com/not-a-sub")
|
||||
if ok {
|
||||
t.Fatal("expected not ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSubscriptionURL(t *testing.T) {
|
||||
body := `{"response":{"subscriptionUrl":"https://vpn.example.com/api/sub/abc"}}`
|
||||
got := extractSubscriptionURL(body)
|
||||
if got != "https://vpn.example.com/api/sub/abc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package remnawave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserInfo is subscription usage metadata from the Remnawave admin API
|
||||
// (GET /api/users/by-short-uuid/{short} + GET /api/hwid/devices/{uuid}).
|
||||
type UserInfo struct {
|
||||
UsedTraffic int64 // bytes
|
||||
TrafficLimit int64 // bytes; 0 = unlimited
|
||||
ExpireAt int64 // unix seconds; 0 = unknown
|
||||
DeviceLimit int // 0 = no limit / unknown
|
||||
Devices int
|
||||
DevicesKnown bool
|
||||
}
|
||||
|
||||
// FetchUserInfo loads traffic/expiry and HWID device info via the admin API.
|
||||
// Requires BaseURL, ShortUUID and Token; degrade gracefully — the caller
|
||||
// should treat any error as "info unavailable".
|
||||
func FetchUserInfo(ctx context.Context, s Settings) (*UserInfo, error) {
|
||||
s.BaseURL = NormalizeBase(s.BaseURL)
|
||||
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
|
||||
s.Token = strings.TrimSpace(s.Token)
|
||||
if s.BaseURL == "" || s.ShortUUID == "" {
|
||||
return nil, fmt.Errorf("нет URL панели / short UUID")
|
||||
}
|
||||
if s.Token == "" {
|
||||
return nil, fmt.Errorf("нет API токена Remnawave")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
endpoint := s.BaseURL + "/api/users/by-short-uuid/" + url.PathEscape(s.ShortUUID)
|
||||
body, _, err := doGET(ctx, client, s, endpoint, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Response userDTO `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &payload); err != nil {
|
||||
// Some deployments return the DTO without the {"response": ...} envelope.
|
||||
var direct userDTO
|
||||
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil {
|
||||
return nil, fmt.Errorf("Remnawave user info: %w", err)
|
||||
}
|
||||
payload.Response = direct
|
||||
}
|
||||
u := payload.Response
|
||||
if u.UUID == "" && u.UsedTrafficBytes == 0 && u.ExpireAt == "" {
|
||||
return nil, fmt.Errorf("Remnawave: пустой ответ user info")
|
||||
}
|
||||
|
||||
info := &UserInfo{
|
||||
UsedTraffic: u.UsedTrafficBytes,
|
||||
TrafficLimit: u.TrafficLimitBytes,
|
||||
DeviceLimit: u.HwidDeviceLimit,
|
||||
}
|
||||
if u.ExpireAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
|
||||
info.ExpireAt = t.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
if u.UUID != "" {
|
||||
if total, ok := fetchDeviceCount(ctx, client, s, u.UUID); ok {
|
||||
info.Devices = total
|
||||
info.DevicesKnown = true
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UUID string `json:"uuid"`
|
||||
UsedTrafficBytes int64 `json:"usedTrafficBytes"`
|
||||
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
|
||||
ExpireAt string `json:"expireAt"`
|
||||
HwidDeviceLimit int `json:"hwidDeviceLimit"`
|
||||
}
|
||||
|
||||
// fetchDeviceCount returns the number of HWID devices attached to a user.
|
||||
// Handles both response shapes: {"response":{"total":N,"devices":[...]}} and
|
||||
// the legacy {"response":[...]}.
|
||||
func fetchDeviceCount(ctx context.Context, client *http.Client, s Settings, userUUID string) (int, bool) {
|
||||
endpoint := s.BaseURL + "/api/hwid/devices/" + url.PathEscape(userUUID)
|
||||
body, _, err := doGET(ctx, client, s, endpoint, true)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
var wrapped struct {
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
raw := json.RawMessage(body)
|
||||
if err := json.Unmarshal([]byte(body), &wrapped); err == nil && len(wrapped.Response) > 0 {
|
||||
raw = wrapped.Response
|
||||
}
|
||||
var obj struct {
|
||||
Total *int `json:"total"`
|
||||
Devices []json.RawMessage `json:"devices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
if obj.Total != nil {
|
||||
return *obj.Total, true
|
||||
}
|
||||
if obj.Devices != nil {
|
||||
return len(obj.Devices), true
|
||||
}
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
return len(arr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
+217
-46
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -25,53 +26,164 @@ type Item struct {
|
||||
URI string
|
||||
}
|
||||
|
||||
// Info is subscription usage metadata from the subscription-userinfo header
|
||||
// (upload/download/total in bytes, expire as unix seconds; 0 = unknown).
|
||||
type Info struct {
|
||||
Upload int64 `json:"upload,omitempty"`
|
||||
Download int64 `json:"download,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Expire int64 `json:"expire,omitempty"`
|
||||
}
|
||||
|
||||
// Result is a parsed subscription: imported items plus per-line skip diagnostics.
|
||||
type Result struct {
|
||||
Items []Item
|
||||
Skipped int
|
||||
Warnings []string
|
||||
Info *Info
|
||||
}
|
||||
|
||||
const maxWarnings = 8
|
||||
|
||||
var (
|
||||
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"']+)`)
|
||||
// reURILine matches a standalone "scheme://..." line — only such lines are
|
||||
// counted as skipped entries (YAML/HTML noise is ignored silently).
|
||||
reURILine = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://\S`)
|
||||
)
|
||||
|
||||
// userAgents to try in order. Remnawave and most panels return raw share links
|
||||
// for a generic UA, but return a Clash YAML config for Clash-like UAs — that
|
||||
// YAML loses vless/trojan entries for us, so try the plain UA first.
|
||||
var userAgents = []string{
|
||||
"EvilFox/4.0 (+https://evilfox.win)",
|
||||
"ClashMeta/1.18.0",
|
||||
}
|
||||
|
||||
// Fetch downloads a subscription body and parses proxy share links.
|
||||
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
|
||||
// Parsing is tolerant: unsupported or broken entries are skipped (with
|
||||
// warnings), valid ones are imported.
|
||||
func Fetch(ctx context.Context, rawURL string) (*Result, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("пустой URL подписки")
|
||||
return nil, fmt.Errorf("пустой URL конфигурации")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return nil, fmt.Errorf("нужен http(s) URL подписки")
|
||||
return nil, fmt.Errorf("нужен http(s) URL конфигурации")
|
||||
}
|
||||
|
||||
var (
|
||||
best *Result
|
||||
info *Info
|
||||
lastErr error
|
||||
)
|
||||
for _, ua := range userAgents {
|
||||
body, header, err := download(ctx, rawURL, ua)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if info == nil {
|
||||
info = ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
|
||||
}
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if res != nil && (best == nil || len(res.Items) > 0 || res.Skipped > best.Skipped) {
|
||||
best = res
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if res != nil && len(res.Items) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if best == nil || len(best.Items) == 0 {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("в конфигурации нет поддерживаемых ссылок")
|
||||
}
|
||||
best.Info = info
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func download(ctx context.Context, rawURL, userAgent string) (string, http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ClashMeta/1.18.0")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось скачать подписку: %w", err)
|
||||
return "", nil, fmt.Errorf("не удалось загрузить конфигурацию: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
|
||||
return "", nil, fmt.Errorf("конфигурация недоступна (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
items, err := ParseBody(string(body))
|
||||
return string(body), resp.Header, nil
|
||||
}
|
||||
|
||||
// ParseUserInfoHeader parses "upload=..; download=..; total=..; expire=<unix>".
|
||||
// Returns nil when the header is absent or has no recognized fields.
|
||||
func ParseUserInfoHeader(v string) *Info {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
info := &Info{}
|
||||
found := false
|
||||
for _, part := range strings.Split(v, ";") {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
continue
|
||||
}
|
||||
return items, nil
|
||||
switch strings.ToLower(strings.TrimSpace(kv[0])) {
|
||||
case "upload":
|
||||
info.Upload, found = n, true
|
||||
case "download":
|
||||
info.Download, found = n, true
|
||||
case "total":
|
||||
info.Total, found = n, true
|
||||
case "expire":
|
||||
info.Expire, found = n, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
|
||||
func ParseBody(body string) ([]Item, error) {
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Items, nil
|
||||
}
|
||||
|
||||
// ParseBodyDetailed parses a subscription body and reports skipped entries.
|
||||
// The returned Result is non-nil even on error (zero valid entries) so callers
|
||||
// can surface the collected warnings.
|
||||
func ParseBodyDetailed(body string) (*Result, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустая подписка")
|
||||
return &Result{}, fmt.Errorf("пустая конфигурация")
|
||||
}
|
||||
|
||||
candidates := []string{body}
|
||||
@@ -83,56 +195,68 @@ func ParseBody(body string) ([]Item, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var best *Result
|
||||
for _, text := range candidates {
|
||||
items, err := parseText(text)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
res := parseText(text)
|
||||
if len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if best == nil || res.Skipped > best.Skipped {
|
||||
best = res
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
if best == nil {
|
||||
best = &Result{}
|
||||
}
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
if best.Skipped > 0 {
|
||||
return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)",
|
||||
best.Skipped, strings.Join(best.Warnings, "; "))
|
||||
}
|
||||
return best, fmt.Errorf("в конфигурации нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||
}
|
||||
|
||||
func parseText(text string) ([]Item, error) {
|
||||
lines := splitLines(text)
|
||||
// Also extract share URIs embedded in YAML/HTML/JSON.
|
||||
extracted := reShareLine.FindAllString(text, -1)
|
||||
for _, m := range extracted {
|
||||
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
lines = append(lines, block)
|
||||
}
|
||||
|
||||
out := make([]Item, 0, len(lines))
|
||||
func parseText(text string) *Result {
|
||||
res := &Result{}
|
||||
seen := map[string]int{}
|
||||
seenURI := map[string]struct{}{}
|
||||
for _, line := range lines {
|
||||
|
||||
// countSkips is true only for raw subscription lines; URIs extracted from
|
||||
// YAML/HTML/JSON noise are best-effort and never counted as "skipped".
|
||||
process := func(line string, countSkips bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
return
|
||||
}
|
||||
line = strings.Trim(line, `"'`)
|
||||
looksURI := reURILine.MatchString(line)
|
||||
skip := func(reason string) {
|
||||
if !countSkips || !looksURI {
|
||||
return
|
||||
}
|
||||
res.Skipped++
|
||||
if len(res.Warnings) < maxWarnings {
|
||||
res.Warnings = append(res.Warnings, shortenLink(line)+" — "+reason)
|
||||
}
|
||||
}
|
||||
|
||||
proto := config.DetectProtocol(line)
|
||||
if proto == "" {
|
||||
continue
|
||||
skip("неподдерживаемая схема")
|
||||
return
|
||||
}
|
||||
// Skip bare https:// without credentials (common noise in HTML/YAML).
|
||||
// Bare https:// without credentials (common noise in HTML/YAML,
|
||||
// or a subscription URL pasted among the links) is not a proxy link.
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||
continue
|
||||
skip("нет логина и пароля в ссылке")
|
||||
return
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
if err != nil {
|
||||
continue
|
||||
skip(err.Error())
|
||||
return
|
||||
}
|
||||
if _, dup := seenURI[normalized]; dup {
|
||||
continue
|
||||
return
|
||||
}
|
||||
seenURI[normalized] = struct{}{}
|
||||
name := remark
|
||||
@@ -147,12 +271,33 @@ func parseText(text string) ([]Item, error) {
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
seen[name] = 1
|
||||
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
|
||||
for _, line := range splitLines(text) {
|
||||
process(line, true)
|
||||
}
|
||||
return out, nil
|
||||
// Also extract share URIs embedded in YAML/HTML/JSON.
|
||||
for _, m := range reShareLine.FindAllString(text, -1) {
|
||||
process(strings.TrimRight(m, ".,;)]}\"'"), false)
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
process(block, false)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// shortenLink trims a link for warning messages, hiding query/credentials noise.
|
||||
func shortenLink(line string) string {
|
||||
if i := strings.IndexAny(line, "?#"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
const max = 48
|
||||
runes := []rune(line)
|
||||
if len(runes) > max {
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func naiveLinkHasAuth(line string) bool {
|
||||
@@ -175,7 +320,7 @@ func extractClashHY2(text string) []string {
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := strings.Split(text, "\n- ")
|
||||
chunks := splitClashItems(text)
|
||||
for _, chunk := range chunks {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
@@ -223,8 +368,34 @@ func extractClashHY2(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// reClashItemStart matches a mapping list item ("- name: X", " - type: y"),
|
||||
// but not nested scalar list entries like "- h3" under alpn.
|
||||
var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`)
|
||||
|
||||
// splitClashItems splits a Clash YAML proxies list into per-proxy chunks,
|
||||
// tolerating any list-item indentation ("- name:" at column 0 or nested).
|
||||
func splitClashItems(text string) []string {
|
||||
lines := splitLines(text)
|
||||
var out []string
|
||||
var cur []string
|
||||
flush := func() {
|
||||
if len(cur) > 0 {
|
||||
out = append(out, strings.Join(cur, "\n"))
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
if reClashItemStart.MatchString(line) {
|
||||
flush()
|
||||
}
|
||||
cur = append(cur, line)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func yamlField(block, key string) string {
|
||||
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
m := re.FindStringSubmatch(block)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -32,6 +35,138 @@ func TestParseBodyBase64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBodyTolerant: one broken entry must never abort the import —
|
||||
// valid links are kept, unsupported/broken lines are skipped with warnings.
|
||||
func TestParseBodyTolerant(t *testing.T) {
|
||||
body := `vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ
|
||||
|
||||
hysteria2://secret@de.example.com:443/?sni=de.example.com#DE-HY2
|
||||
https://api.example.com/ZKszcVcC3xbWb8qj
|
||||
foobar://what@is.this:1
|
||||
# comment line
|
||||
`
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("imported %d items, want 2: %+v", len(res.Items), res.Items)
|
||||
}
|
||||
if res.Items[0].Protocol != "vless" || res.Items[1].Protocol != "hysteria2" {
|
||||
t.Fatalf("protocols: %s, %s", res.Items[0].Protocol, res.Items[1].Protocol)
|
||||
}
|
||||
// https:// without userinfo + unknown scheme → skipped; empty/comment lines are not counted.
|
||||
if res.Skipped != 2 {
|
||||
t.Fatalf("skipped %d, want 2 (%v)", res.Skipped, res.Warnings)
|
||||
}
|
||||
if len(res.Warnings) != 2 {
|
||||
t.Fatalf("warnings: %v", res.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBodyZeroValidListsReasons(t *testing.T) {
|
||||
body := "https://api.example.com/sub-token-line\nfoobar://x@y:1\n"
|
||||
res, err := ParseBodyDetailed(body)
|
||||
if err == nil {
|
||||
t.Fatal("want error for zero valid entries")
|
||||
}
|
||||
if res.Skipped != 2 {
|
||||
t.Fatalf("skipped %d, want 2", res.Skipped)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "2 записей пропущено") {
|
||||
t.Fatalf("error should list skip count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUserInfoHeader(t *testing.T) {
|
||||
info := ParseUserInfoHeader("upload=0; download=5679561725; total=53687091200000; expire=1799366585")
|
||||
if info == nil {
|
||||
t.Fatal("nil info")
|
||||
}
|
||||
if info.Download != 5679561725 || info.Total != 53687091200000 || info.Expire != 1799366585 || info.Upload != 0 {
|
||||
t.Fatalf("bad parse: %+v", info)
|
||||
}
|
||||
if ParseUserInfoHeader("") != nil {
|
||||
t.Fatal("empty header must give nil")
|
||||
}
|
||||
if ParseUserInfoHeader("garbage") != nil {
|
||||
t.Fatal("garbage header must give nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Remnawave returns Clash YAML for Clash-like UAs and raw base64 links otherwise.
|
||||
// Fetch must obtain the full server list (plain UA) and the userinfo header.
|
||||
func TestFetchPrefersPlainUA(t *testing.T) {
|
||||
links := "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ\n" +
|
||||
"trojan://pass@no.example.com:20000?security=tls&type=ws#NO\n" +
|
||||
"hysteria2://secret@de.example.com:443/?sni=de.example.com#DE\n" +
|
||||
"ss://YWVzLTI1Ni1nY206cGFzcw@ss.example.com:8388#SS\n"
|
||||
clashYAML := "proxies:\n - name: only-hy2\n type: hysteria2\n server: de.example.com\n port: 443\n password: secret\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=100; expire=1799366585")
|
||||
if strings.Contains(strings.ToLower(r.Header.Get("User-Agent")), "clash") {
|
||||
_, _ = w.Write([]byte(clashYAML))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString([]byte(links))))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := Fetch(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 3 {
|
||||
t.Fatalf("imported %d, want 3 (vless+trojan+hy2): %+v", len(res.Items), res.Items)
|
||||
}
|
||||
if res.Skipped != 1 {
|
||||
t.Fatalf("skipped %d, want 1 (ss://)", res.Skipped)
|
||||
}
|
||||
if res.Info == nil || res.Info.Expire != 1799366585 || res.Info.Total != 100 {
|
||||
t.Fatalf("info: %+v", res.Info)
|
||||
}
|
||||
}
|
||||
|
||||
// Indented Clash proxies with nested lists (alpn) must split into separate blocks.
|
||||
func TestParseBodyClashYAMLIndented(t *testing.T) {
|
||||
body := `proxies:
|
||||
- name: DE HY2
|
||||
type: hysteria2
|
||||
server: de.example.com
|
||||
port: 443
|
||||
udp: true
|
||||
password: secret1
|
||||
sni: de.example.com
|
||||
alpn:
|
||||
- h3
|
||||
- name: NO HY2
|
||||
type: hysteria2
|
||||
server: no.example.com
|
||||
port: 9000
|
||||
password: secret2
|
||||
alpn:
|
||||
- h3
|
||||
`
|
||||
items, err := ParseBody(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items, want 2: %+v", len(items), items)
|
||||
}
|
||||
for i, host := range []string{"de.example.com:443", "no.example.com:9000"} {
|
||||
if !strings.Contains(items[i].URI, host) {
|
||||
t.Fatalf("item %d uri %s missing %s", i, items[i].URI, host)
|
||||
}
|
||||
}
|
||||
for i, pass := range []string{"secret1", "secret2"} {
|
||||
if !strings.Contains(items[i].URI, pass) {
|
||||
t.Fatalf("item %d uri %s missing password %s", i, items[i].URI, pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBodyClashYAML(t *testing.T) {
|
||||
body := `
|
||||
proxies:
|
||||
|
||||
@@ -18,14 +18,14 @@ import (
|
||||
|
||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||
// Keep major.minor.patch only — no build suffix here.
|
||||
const CurrentVersion = "3.8.2"
|
||||
const CurrentVersion = "4.2.4"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
const BuildNumber = 1
|
||||
|
||||
// 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.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/update.json"
|
||||
|
||||
// DisplayVersion is shown in the UI (product + build), e.g. "2.8.0+1".
|
||||
func DisplayVersion() string {
|
||||
@@ -274,7 +274,8 @@ func splitVer(v string) []int {
|
||||
|
||||
func allowedDownloadURL(u string) bool {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
return strings.HasPrefix(u, "https://git.evilfox.cc/") ||
|
||||
return strings.HasPrefix(u, "https://git.de4ima.uk/") ||
|
||||
strings.HasPrefix(u, "https://git.evilfox.cc/") ||
|
||||
strings.HasPrefix(u, "https://files.de4ima.uk/")
|
||||
}
|
||||
|
||||
@@ -286,7 +287,7 @@ func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) {
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+DisplayVersion())
|
||||
req.Header.Set("User-Agent", "EvilFox/"+DisplayVersion())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
@@ -309,7 +310,7 @@ func downloadFile(ctx context.Context, url, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+DisplayVersion())
|
||||
req.Header.Set("User-Agent", "EvilFox/"+DisplayVersion())
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -355,7 +356,7 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.de4ima.uk, git.evilfox.cc или files.de4ima.uk")
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// finishUpdateFlag must stay "--navis-finish-update" forever: older installed
|
||||
// builds (3.x) launch the freshly downloaded binary with exactly this flag,
|
||||
// so renaming it would break their update handoff.
|
||||
const finishUpdateFlag = "--navis-finish-update"
|
||||
|
||||
// Apply downloads the new build and replaces the running Navis.exe after this process exits.
|
||||
// Apply downloads the new build and replaces the running EvilFox.exe after this process exits.
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
if IsStorePackaged() {
|
||||
return "", fmt.Errorf("обновления устанавливаются через Microsoft Store")
|
||||
@@ -27,7 +30,7 @@ func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pending := filepath.Join(filepath.Dir(exe), "Navis-pending.exe")
|
||||
pending := filepath.Join(filepath.Dir(exe), "EvilFox-pending.exe")
|
||||
_ = os.Remove(pending)
|
||||
if err := os.Rename(tmp, pending); err != nil {
|
||||
if err2 := copyFile(tmp, pending); err2 != nil {
|
||||
@@ -58,7 +61,7 @@ func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// MaybeFinishUpdate handles: Navis-pending.exe --navis-finish-update <pid> <targetExe>
|
||||
// MaybeFinishUpdate handles: EvilFox-pending.exe --navis-finish-update <pid> <targetExe>
|
||||
// Replaces targetExe in-place with this binary, then relaunches it.
|
||||
func MaybeFinishUpdate(args []string) bool {
|
||||
if len(args) < 3 || args[0] != finishUpdateFlag {
|
||||
@@ -109,6 +112,7 @@ func CleanupStaleDownloads() {
|
||||
_ = os.Remove(filepath.Join(dir, base+".new"))
|
||||
_ = os.Remove(filepath.Join(dir, "Navis-update.part"))
|
||||
_ = os.Remove(filepath.Join(dir, ".navis-update-download"))
|
||||
_ = os.Remove(filepath.Join(dir, "Navis-pending.exe"))
|
||||
}
|
||||
|
||||
func replaceExecutable(src, dst string) error {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package vpnmode implements "Режим VPN": a wintun TUN device that captures
|
||||
// ALL system traffic (apps/games included) and feeds it into the local SOCKS
|
||||
// proxy of the active protocol engine via the embedded tun2socks stack.
|
||||
//
|
||||
// Windows-only for now. On other platforms Supported() returns false and the
|
||||
// UI hides the switch (no fake toggle).
|
||||
package vpnmode
|
||||
|
||||
import "io"
|
||||
|
||||
// Config describes one VPN-mode session.
|
||||
type Config struct {
|
||||
// SOCKSAddr is the host:port of the running local SOCKS5 proxy.
|
||||
SOCKSAddr string
|
||||
// ExcludeHosts are VPN server endpoints (host names or IPs) that must
|
||||
// bypass the TUN default route, otherwise the tunnel would loop.
|
||||
ExcludeHosts []string
|
||||
// DNSServers go through the tunnel (default 1.1.1.1 + 8.8.8.8).
|
||||
DNSServers []string
|
||||
// Stderr receives diagnostic messages (may be nil).
|
||||
Stderr io.Writer
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package vpnmode
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Supported: VPN mode ships Windows-first; the UI hides the switch elsewhere.
|
||||
func Supported() bool { return false }
|
||||
|
||||
// IsElevated is meaningless off Windows.
|
||||
func IsElevated() bool { return false }
|
||||
|
||||
// WintunAvailable is Windows-only.
|
||||
func WintunAvailable() bool { return false }
|
||||
|
||||
// Session is a stub off Windows.
|
||||
type Session struct{}
|
||||
|
||||
// Start always fails off Windows.
|
||||
func Start(cfg Config) (*Session, error) {
|
||||
return nil, fmt.Errorf("режим VPN пока доступен только на Windows")
|
||||
}
|
||||
|
||||
// Close is a no-op stub.
|
||||
func (s *Session) Close() error { return nil }
|
||||
@@ -0,0 +1,281 @@
|
||||
//go:build windows
|
||||
|
||||
package vpnmode
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
|
||||
t2core "github.com/xjasonlyu/tun2socks/v2/core"
|
||||
"github.com/xjasonlyu/tun2socks/v2/core/device"
|
||||
t2tun "github.com/xjasonlyu/tun2socks/v2/core/device/tun"
|
||||
t2log "github.com/xjasonlyu/tun2socks/v2/log"
|
||||
"github.com/xjasonlyu/tun2socks/v2/proxy/socks5"
|
||||
"github.com/xjasonlyu/tun2socks/v2/tunnel"
|
||||
)
|
||||
|
||||
const (
|
||||
tunName = "NavisTun"
|
||||
tunAddr = "10.255.99.2"
|
||||
tunMask = "255.255.255.0"
|
||||
// On-link gateway inside the TUN subnet; wintun swallows everything anyway.
|
||||
tunGateway = "10.255.99.1"
|
||||
)
|
||||
|
||||
// Supported reports that VPN mode is available on this OS.
|
||||
func Supported() bool { return true }
|
||||
|
||||
// IsElevated reports whether the process runs with administrator rights.
|
||||
func IsElevated() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// WintunAvailable checks that wintun.dll can be found (exe dir or System32).
|
||||
func WintunAvailable() bool {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if st, err := os.Stat(filepath.Join(filepath.Dir(exe), "wintun.dll")); err == nil && !st.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if sysDir, err := windows.GetSystemDirectory(); err == nil {
|
||||
if st, err := os.Stat(filepath.Join(sysDir, "wintun.dll")); err == nil && !st.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Session is a running VPN-mode capture (TUN + tun2socks + routes).
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
dev device.Device
|
||||
stack *stack.Stack
|
||||
excluded []exclusionRoute // host routes added via route.exe, removed on Close
|
||||
stderr io.Writer
|
||||
closed bool
|
||||
setupDone bool
|
||||
}
|
||||
|
||||
type exclusionRoute struct {
|
||||
ip string
|
||||
gateway string
|
||||
ifIndex uint32
|
||||
}
|
||||
|
||||
// Start opens the wintun adapter, wires it to the local SOCKS proxy and takes
|
||||
// over the default route (0.0.0.0/1 + 128.0.0.0/1). Requires admin+wintun.dll.
|
||||
func Start(cfg Config) (*Session, error) {
|
||||
if cfg.Stderr == nil {
|
||||
cfg.Stderr = os.Stderr
|
||||
}
|
||||
if strings.TrimSpace(cfg.SOCKSAddr) == "" {
|
||||
return nil, fmt.Errorf("режим VPN: нет локального SOCKS-прокси у текущего протокола")
|
||||
}
|
||||
if !IsElevated() {
|
||||
return nil, fmt.Errorf("Запустите EvilFox от имени администратора для режима VPN")
|
||||
}
|
||||
if !WintunAvailable() {
|
||||
return nil, fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с EvilFox.exe")
|
||||
}
|
||||
if len(cfg.DNSServers) == 0 {
|
||||
cfg.DNSServers = []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
|
||||
// Resolve exclusion routes BEFORE the default route changes, otherwise
|
||||
// DNS + GetBestRoute would already point into the (not yet working) TUN.
|
||||
var excls []exclusionRoute
|
||||
for _, h := range cfg.ExcludeHosts {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
ips, err := net.LookupIP(h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("режим VPN: не удалось определить IP сервера %s: %w", h, err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
ip4 := ip.To4()
|
||||
if ip4 == nil || ip4.IsLoopback() || ip4.IsPrivate() {
|
||||
continue
|
||||
}
|
||||
gw, ifIdx, err := bestRoute(ip4)
|
||||
if err != nil {
|
||||
fmt.Fprintf(cfg.Stderr, "vpnmode: GetBestRoute %s: %v\n", ip4, err)
|
||||
continue
|
||||
}
|
||||
excls = append(excls, exclusionRoute{ip: ip4.String(), gateway: gw.String(), ifIndex: ifIdx})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep tun2socks quiet unless something is wrong.
|
||||
if lvl, err := t2log.ParseLevel("warning"); err == nil {
|
||||
t2log.SetLogger(t2log.Must(t2log.NewLeveled(lvl)))
|
||||
}
|
||||
|
||||
px, err := socks5.New(cfg.SOCKSAddr, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("режим VPN: socks5 %s: %w", cfg.SOCKSAddr, err)
|
||||
}
|
||||
tunnel.T().SetProxy(px)
|
||||
|
||||
dev, err := t2tun.Open(tunName, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("режим VPN: не удалось создать TUN-адаптер (wintun): %w", err)
|
||||
}
|
||||
st, err := t2core.CreateStack(&t2core.Config{
|
||||
LinkEndpoint: dev,
|
||||
TransportHandler: tunnel.T(),
|
||||
})
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("режим VPN: сетевой стек: %w", err)
|
||||
}
|
||||
|
||||
s := &Session{dev: dev, stack: st, stderr: cfg.Stderr}
|
||||
if err := s.setupNetwork(cfg, excls); err != nil {
|
||||
_ = s.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) setupNetwork(cfg Config, excls []exclusionRoute) error {
|
||||
// The adapter needs a moment to register with the IP stack.
|
||||
var lastErr error
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := net.InterfaceByName(tunName); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("режим VPN: адаптер %s не появился: %w", tunName, lastErr)
|
||||
}
|
||||
|
||||
if out, err := runHidden("netsh", "interface", "ipv4", "set", "address",
|
||||
"name="+tunName, "source=static", "addr="+tunAddr, "mask="+tunMask); err != nil {
|
||||
return fmt.Errorf("режим VPN: настройка адреса TUN: %v (%s)", err, out)
|
||||
}
|
||||
// DNS goes through the tunnel (queries hit the default route → TUN).
|
||||
if out, err := runHidden("netsh", "interface", "ipv4", "set", "dnsservers",
|
||||
"name="+tunName, "source=static", "address="+cfg.DNSServers[0], "register=none", "validate=no"); err != nil {
|
||||
fmt.Fprintf(s.stderr, "vpnmode: set dns: %v (%s)\n", err, out)
|
||||
}
|
||||
for i, d := range cfg.DNSServers[1:] {
|
||||
if out, err := runHidden("netsh", "interface", "ipv4", "add", "dnsservers",
|
||||
"name="+tunName, "address="+d, "index="+strconv.Itoa(i+2), "validate=no"); err != nil {
|
||||
fmt.Fprintf(s.stderr, "vpnmode: add dns: %v (%s)\n", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Exclusions first — the server endpoint must keep using the real uplink.
|
||||
for _, e := range excls {
|
||||
if e.gateway == "0.0.0.0" || e.gateway == "" {
|
||||
continue
|
||||
}
|
||||
if out, err := runHidden("route", "add", e.ip, "mask", "255.255.255.255",
|
||||
e.gateway, "metric", "1", "if", strconv.FormatUint(uint64(e.ifIndex), 10)); err != nil {
|
||||
return fmt.Errorf("режим VPN: маршрут-исключение для %s: %v (%s)", e.ip, err, out)
|
||||
}
|
||||
s.excluded = append(s.excluded, e)
|
||||
}
|
||||
|
||||
// Two half-default routes beat 0.0.0.0/0 without touching the original.
|
||||
for _, prefix := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
if out, err := runHidden("netsh", "interface", "ipv4", "add", "route",
|
||||
prefix, tunName, tunGateway, "metric=1", "store=active"); err != nil {
|
||||
return fmt.Errorf("режим VPN: маршрут %s: %v (%s)", prefix, err, out)
|
||||
}
|
||||
}
|
||||
s.setupDone = true
|
||||
fmt.Fprintf(s.stderr, "vpnmode: TUN %s активен, весь трафик через %s (исключений: %d)\n",
|
||||
tunName, cfg.SOCKSAddr, len(s.excluded))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close removes exclusion routes and destroys the TUN adapter (its own routes
|
||||
// disappear together with the adapter).
|
||||
func (s *Session) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
for _, e := range s.excluded {
|
||||
if out, err := runHidden("route", "delete", e.ip, "mask", "255.255.255.255", e.gateway); err != nil {
|
||||
fmt.Fprintf(s.stderr, "vpnmode: route delete %s: %v (%s)\n", e.ip, err, out)
|
||||
}
|
||||
}
|
||||
s.excluded = nil
|
||||
if s.dev != nil {
|
||||
s.dev.Close()
|
||||
s.dev = nil
|
||||
}
|
||||
if s.stack != nil {
|
||||
s.stack.Close()
|
||||
s.stack = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHidden(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
var (
|
||||
modiphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetBestRoute = modiphlpapi.NewProc("GetBestRoute")
|
||||
)
|
||||
|
||||
// mibIPForwardRow mirrors MIB_IPFORWARDROW (iphlpapi).
|
||||
type mibIPForwardRow struct {
|
||||
Dest uint32
|
||||
Mask uint32
|
||||
Policy uint32
|
||||
NextHop uint32
|
||||
IfIndex uint32
|
||||
Type uint32
|
||||
Proto uint32
|
||||
Age uint32
|
||||
NextHopAS uint32
|
||||
Metric1 uint32
|
||||
Metric2 uint32
|
||||
Metric3 uint32
|
||||
Metric4 uint32
|
||||
Metric5 uint32
|
||||
}
|
||||
|
||||
// bestRoute asks Windows for the current gateway + interface toward ip
|
||||
// (IPv4, network byte order DWORDs).
|
||||
func bestRoute(ip4 net.IP) (net.IP, uint32, error) {
|
||||
dest := binary.LittleEndian.Uint32(ip4.To4())
|
||||
var row mibIPForwardRow
|
||||
r, _, _ := procGetBestRoute.Call(uintptr(dest), 0, uintptr(unsafe.Pointer(&row)))
|
||||
if r != 0 {
|
||||
return nil, 0, fmt.Errorf("GetBestRoute: код %d", r)
|
||||
}
|
||||
gw := make(net.IP, 4)
|
||||
binary.LittleEndian.PutUint32(gw, row.NextHop)
|
||||
return gw, row.IfIndex, nil
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Navis MSIX package manifest (Microsoft Store / sideload).
|
||||
EvilFox MSIX package manifest (Microsoft Store / sideload).
|
||||
|
||||
WHY MSIX (not PWA):
|
||||
Navis is a Win32 + WebView2 desktop VPN/proxy client (system proxy, local cores,
|
||||
EvilFox is a Win32 + WebView2 desktop VPN/proxy client (system proxy, local cores,
|
||||
Naive/Hy2/AWG/Xray). A PWA cannot replace those capabilities. Package the existing
|
||||
GUI binary with Desktop Bridge / full-trust MSIX for Store distribution.
|
||||
|
||||
IDENTITY PLACEHOLDERS — replace before Store submission:
|
||||
Identity/@Name → Partner Center "Package/Identity/Name" (e.g. EvilFox.Navis)
|
||||
Identity/@Name → Partner Center "Package/Identity/Name" (e.g. EvilFox.EvilFox)
|
||||
Identity/@Publisher → exact Publisher CN from your Store certificate
|
||||
(e.g. CN=EvilFox LLC or CN=A1B2C3D4-…)
|
||||
Identity/@Version → Major.Minor.Build.Revision (all numeric; Revision often 0)
|
||||
@@ -31,15 +31,15 @@
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity
|
||||
Name="EvilFox.Navis"
|
||||
Name="EvilFox.EvilFox"
|
||||
Publisher="CN=EvilFox"
|
||||
Version="3.8.2.1"
|
||||
Version="4.2.4.1"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Navis</DisplayName>
|
||||
<DisplayName>EvilFox</DisplayName>
|
||||
<PublisherDisplayName>EvilFox</PublisherDisplayName>
|
||||
<Description>Navis — VPN/proxy client (Naive, Hysteria 2, AmneziaWG, Xray)</Description>
|
||||
<Description>EvilFox — VPN/proxy client (Naive, Hysteria 2, AmneziaWG, Xray)</Description>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
@@ -61,12 +61,12 @@
|
||||
|
||||
<Applications>
|
||||
<Application
|
||||
Id="Navis"
|
||||
Executable="Navis.exe"
|
||||
Id="EvilFox"
|
||||
Executable="EvilFox.exe"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="Navis"
|
||||
Description="Navis VPN client"
|
||||
DisplayName="EvilFox"
|
||||
Description="EvilFox VPN client"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
@@ -74,7 +74,7 @@
|
||||
Wide310x150Logo="Assets\Wide310x150Logo.png"
|
||||
Square71x71Logo="Assets\Square71x71Logo.png"
|
||||
Square310x310Logo="Assets\Square310x310Logo.png"
|
||||
ShortName="Navis" />
|
||||
ShortName="EvilFox" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#0c1613" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
|
||||
@@ -39,7 +39,19 @@ Output: `dist\navis-release\windows\Navis_<version>.msix`
|
||||
## Sideload test
|
||||
|
||||
```powershell
|
||||
Add-AppxPackage -Path .\dist\navis-release\windows\Navis_2.9.0.1.msix
|
||||
Add-AppxPackage -Path .\dist\navis-release\windows\Navis_3.9.0.1.msix
|
||||
```
|
||||
|
||||
Unsigned packages need a trusted cert or developer mode / sideloading enabled.
|
||||
|
||||
## Remnawave notes
|
||||
|
||||
Navis can pull configs from a Remnawave panel:
|
||||
|
||||
1. Public subscription: paste `https://<panel>/api/sub/<shortUuid>` (or use panel URL + short UUID in the Remnawave block).
|
||||
2. Optional API token (panel → API Tokens) enables authenticated lookups:
|
||||
- `GET /api/subscriptions/by-short-uuid/{short}` / `…/raw`
|
||||
- `GET /api/users/by-short-uuid/{short}` → `subscriptionUrl`
|
||||
3. Optional Caddy `X-Api-Key` if the panel sits behind Caddy token auth.
|
||||
|
||||
Tokens are stored only in local `configs/config.json` (mode 0600) — never commit them.
|
||||
|
||||
+18
-18
@@ -1,24 +1,24 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pack Navis.exe into an MSIX for Microsoft Store / sideload.
|
||||
Pack EvilFox.exe into an MSIX for Microsoft Store / sideload.
|
||||
|
||||
.DESCRIPTION
|
||||
Builds Store assets, stages AppxManifest + Navis.exe, runs MakeAppx.
|
||||
Prefers MSIX over PWA: Navis needs Win32/WebView2 VPN and system-proxy access.
|
||||
Builds Store assets, stages AppxManifest + EvilFox.exe, runs MakeAppx.
|
||||
Prefers MSIX over PWA: EvilFox needs Win32/WebView2 VPN and system-proxy access.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\pack-msix.ps1
|
||||
.\scripts\pack-msix.ps1 -Version 3.8.2.1 -Publisher "CN=EvilFox"
|
||||
.\scripts\pack-msix.ps1 -Version 4.0.1.1 -Publisher "CN=EvilFox"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ExePath = "",
|
||||
[string]$OutDir = "",
|
||||
[string]$Version = "3.8.2.1",
|
||||
[string]$Name = "EvilFox.Navis",
|
||||
[string]$Version = "4.2.3.1",
|
||||
[string]$Name = "EvilFox.EvilFox",
|
||||
[string]$Publisher = "CN=EvilFox",
|
||||
[string]$DisplayName = "Navis",
|
||||
[string]$DisplayName = "EvilFox",
|
||||
[string]$PublisherDisplayName = "EvilFox",
|
||||
[string]$CertPath = "",
|
||||
[string]$CertPassword = "",
|
||||
@@ -30,14 +30,14 @@ $Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
Set-Location $Root
|
||||
|
||||
if (-not $ExePath) {
|
||||
$ExePath = Join-Path $Root "dist\navis-release\windows\Navis.exe"
|
||||
$ExePath = Join-Path $Root "dist\navis-release\windows\EvilFox.exe"
|
||||
}
|
||||
if (-not $OutDir) {
|
||||
$OutDir = Join-Path $Root "dist\navis-release\windows"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ExePath)) {
|
||||
throw "Navis.exe not found at '$ExePath'. Run build.bat first."
|
||||
throw "EvilFox.exe not found at '$ExePath'. Run build.bat first."
|
||||
}
|
||||
|
||||
function Find-SdkTool([string]$name) {
|
||||
@@ -74,30 +74,30 @@ New-Item -ItemType Directory -Path (Join-Path $Staging "Assets") | Out-Null
|
||||
|
||||
$AssetsSrc = Join-Path $Root "packaging\msix\Assets"
|
||||
Copy-Item -Path (Join-Path $AssetsSrc "*") -Destination (Join-Path $Staging "Assets") -Force
|
||||
Copy-Item -LiteralPath $ExePath -Destination (Join-Path $Staging "Navis.exe") -Force
|
||||
Copy-Item -LiteralPath $ExePath -Destination (Join-Path $Staging "EvilFox.exe") -Force
|
||||
|
||||
$ManifestSrc = Join-Path $Root "packaging\msix\AppxManifest.xml"
|
||||
$ManifestDst = Join-Path $Staging "AppxManifest.xml"
|
||||
$manifestText = Get-Content -LiteralPath $ManifestSrc -Raw -Encoding UTF8
|
||||
# Prefer exact placeholder swaps so we never touch <?xml version=...?>.
|
||||
$manifestText = $manifestText.Replace('Name="EvilFox.Navis"', "Name=`"$Name`"")
|
||||
$manifestText = $manifestText.Replace('Name="EvilFox.EvilFox"', "Name=`"$Name`"")
|
||||
$manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"")
|
||||
$manifestText = $manifestText.Replace('Version="3.8.2.1"', "Version=`"$Version`"")
|
||||
$manifestText = $manifestText.Replace('<DisplayName>Navis</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
|
||||
$manifestText = $manifestText.Replace('Version="4.2.3.1"', "Version=`"$Version`"")
|
||||
$manifestText = $manifestText.Replace('<DisplayName>EvilFox</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
|
||||
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
||||
$manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"")
|
||||
$manifestText = $manifestText.Replace('DisplayName="EvilFox"', "DisplayName=`"$DisplayName`"")
|
||||
[System.IO.File]::WriteAllText($ManifestDst, $manifestText, (New-Object System.Text.UTF8Encoding $false))
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
$MsixPath = Join-Path $OutDir ("Navis_" + $Version + ".msix")
|
||||
$MsixPath = Join-Path $OutDir ("EvilFox_" + $Version + ".msix")
|
||||
if (Test-Path $MsixPath) { Remove-Item -Force $MsixPath }
|
||||
|
||||
if (-not $MakeAppx) {
|
||||
Write-Host ""
|
||||
Write-Host "MakeAppx.exe not found — install the Windows 10/11 SDK to produce .msix." -ForegroundColor Yellow
|
||||
Write-Host "MakeAppx.exe not found - install the Windows 10/11 SDK to produce .msix." -ForegroundColor Yellow
|
||||
Write-Host "Staging is ready: $Staging"
|
||||
Write-Host "Then run:"
|
||||
Write-Host " makeappx pack /d `"$Staging`" /p `"$MsixPath`" /o"
|
||||
Write-Host (" makeappx pack /d `"{0}`" /p `"{1}`" /o" -f $Staging, $MsixPath)
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -123,4 +123,4 @@ Write-Host " $MsixPath"
|
||||
Write-Host " Staging: $Staging"
|
||||
Write-Host ""
|
||||
Write-Host "Store: upload MSIX in Partner Center (identity must match reservation)."
|
||||
Write-Host "Sideload: Add-AppxPackage -Path `"$MsixPath`""
|
||||
Write-Host ("Sideload: Add-AppxPackage -Path `"{0}`"" -f $MsixPath)
|
||||
|
||||
+35
-35
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "3.8.2: Windows product version 3.8.2.1 (build 1).",
|
||||
"version": "4.2.4",
|
||||
"notes": "4.2.4: группировка серверов по хосту — один физический сервер с протоколами VLESS · Trojan · Hysteria2 вместо «сервер 2/3»; «оптимальный» среди разных хостов",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "8049f973e8e8712b31fccac42c48be5eb436a5a6bf103eb979729e1cbfdec8ec",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"android": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/android/Navis.apk",
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/android/Navis.apk",
|
||||
"sha256": "4416770bebe576eba42811990c4e4424f6722dea3a8513e6164dd34fc975b3cf",
|
||||
"os": "android",
|
||||
"arch": "universal"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis",
|
||||
"sha256": "19a066cfc7f7c3a291a220482b28e66a459d091c19c5351818c401b982bc65b3",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/amd64/Navis.app.zip"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis",
|
||||
"sha256": "01a40b1cce4e2fb3a38eb80f0815c295a00af11e4288f3b9e5c0b58ae336f28d",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/arm64/Navis.app.zip"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis",
|
||||
"sha256": "d53dba8256fd6f7b62d1ce9bbad741af087ae44fec942bf2782b9c8f4e36cef2",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.dmg",
|
||||
"zip_url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/macos/universal/Navis.app.zip"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||
"sha256": "3cebbdf3ea5e4bdac251e9fe18efbde59dd2c425ad57b0909b879a58dbd1a046",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Temporary UI preview server for development: serves the embedded UI +
|
||||
// real /api handlers on a fixed local port. Not shipped.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"vpnclient/internal/apphost"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath, err := config.LocateConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
logBuf := &bytes.Buffer{}
|
||||
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
a := apphost.New(mgr, cfgPath, logBuf)
|
||||
addr := os.Getenv("NAVIS_PREVIEW_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8765" // may clash with a running app — override via NAVIS_PREVIEW_ADDR
|
||||
}
|
||||
fmt.Println("preview on http://" + addr + "/")
|
||||
log.Fatal(http.ListenAndServe(addr, a.Handler()))
|
||||
}
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 3, "Minor": 8, "Patch": 2, "Build": 1 },
|
||||
"ProductVersion": { "Major": 3, "Minor": 8, "Patch": 2, "Build": 1 },
|
||||
"FileVersion": { "Major": 4, "Minor": 2, "Patch": 4, "Build": 1 },
|
||||
"ProductVersion": { "Major": 4, "Minor": 2, "Patch": 4, "Build": 1 },
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
@@ -10,13 +10,13 @@
|
||||
},
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "EvilFox",
|
||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "3.8.2.1",
|
||||
"InternalName": "Navis",
|
||||
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||
"FileVersion": "4.2.4.1",
|
||||
"InternalName": "EvilFox",
|
||||
"LegalCopyright": "Copyright (c) EvilFox",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "3.8.2.1",
|
||||
"OriginalFilename": "EvilFox.exe",
|
||||
"ProductName": "EvilFox",
|
||||
"ProductVersion": "4.2.4.1",
|
||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
Reference in New Issue
Block a user