diff --git a/.gitignore b/.gitignore index 51a1266..89ae641 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,6 @@ bin/ configs/config.json configs/remnawave-api.json configs/account.json -# Baked panel credentials (copied by build.bat; never commit) -internal/remnawave/embeddedcfg/remnawave-api.json dist/navis-release/windows/configs/ # Runtime-downloaded protocol cores + local packaging artifact (not release sources) dist/navis-release/windows/bin/ diff --git a/README.md b/README.md index 683cdaa..5ee0c97 100644 --- a/README.md +++ b/README.md @@ -232,29 +232,6 @@ build.bat Токены хранятся только локально в `configs/config.json` (не коммитьте). -### Выдача доступа из приложения (admin API) - -Приложение умеет само создавать пользователей в панели Remnawave и сразу импортировать их конфиги (панель «Выдать доступ» в интерфейсе). Для этого нужен **отдельный** файл с админ-ключом: - -1. Скопируйте `configs/remnawave-api.example.json` → `configs/remnawave-api.json`. -2. Вставьте `panel_url` и `api_token` (панель → API Tokens). `caddy_api_key` — только если панель за Caddy-auth. -3. Перезапустите Navis — появится панель «Выдать доступ». - -Файл читается **в рантайме** из папки `configs` рядом с `config.json` — пересборка (repack) не требуется, достаточно положить файл и перезапустить приложение. - -Тариф по умолчанию (настраивается в блоке `provision`): - -| Поле | Значение по умолчанию | -|------|----------------------| -| `traffic_gb` | 50 ГБ | -| `days` | 30 дней (expireAt = сейчас + days) | -| `strategy` | `MONTH` (сброс трафика; также `NO_RESET` / `DAY` / `WEEK`) | -| `hwid_device_limit` | 0 = без лимита устройств | - -Логика: `GET /api/users/by-username/{username}` — если пользователь есть, берётся его `subscriptionUrl`; иначе `POST /api/users` с тарифом выше, затем подписка импортируется в приложение автоматически. - -`configs/remnawave-api.json` в `.gitignore` — **никогда не коммитьте** реальный ключ, в репозитории только `remnawave-api.example.json`. - ## Сборка macOS (кросс с Windows / Linux) ```bat diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f77521d..9528c23 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { minSdk = 26 targetSdk = 35 // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build - versionCode = 4_000_001 - versionName = "4.0.0+1" + versionCode = 4_000_101 + versionName = "4.0.1+1" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build.bat b/build.bat index a5ee9c6..39de8f5 100644 --- a/build.bat +++ b/build.bat @@ -14,16 +14,6 @@ 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 -REM Bake Remnawave panel credentials into the binary (NordVPN-style centralized provisioning). -REM Source of truth: configs\remnawave-api.json (gitignored). Never commit the embedded copy. -if exist "configs\remnawave-api.json" ( - mkdir "internal\remnawave\embeddedcfg" 2>nul - copy /Y "configs\remnawave-api.json" "internal\remnawave\embeddedcfg\remnawave-api.json" >nul - echo Embedded panel credentials from configs\remnawave-api.json -) else ( - echo WARNING: configs\remnawave-api.json missing — build will use external file / no auto-provision -) - 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 diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index 5c70b06..a555a5e 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -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" @@ -40,31 +41,29 @@ type app struct { } type uiState struct { - Connected bool `json:"connected"` - Profile string `json:"profile,omitempty"` - ActiveProfile string `json:"active_profile,omitempty"` - Protocol string `json:"protocol,omitempty"` - HTTPProxy string `json:"http_proxy,omitempty"` - SOCKSProxy string `json:"socks_proxy,omitempty"` - SystemProxy bool `json:"system_proxy"` - Proxy string `json:"proxy"` - CoreReady bool `json:"core_ready"` - CorePath string `json:"core_path,omitempty"` - ConfigPath string `json:"config_path"` - Profiles []config.ProfileInfo `json:"profiles"` - Version string `json:"version"` - 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"` - ProvisionReady bool `json:"provision_ready,omitempty"` - Account *remnawave.Account `json:"account,omitempty"` - Mode string `json:"mode"` - VPNSupported bool `json:"vpn_mode_supported"` - VPNActive bool `json:"vpn_active"` + Connected bool `json:"connected"` + Profile string `json:"profile,omitempty"` + ActiveProfile string `json:"active_profile,omitempty"` + Protocol string `json:"protocol,omitempty"` + HTTPProxy string `json:"http_proxy,omitempty"` + SOCKSProxy string `json:"socks_proxy,omitempty"` + SystemProxy bool `json:"system_proxy"` + Proxy string `json:"proxy"` + CoreReady bool `json:"core_ready"` + CorePath string `json:"core_path,omitempty"` + ConfigPath string `json:"config_path"` + Profiles []config.ProfileInfo `json:"profiles"` + Version string `json:"version"` + 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"` } func main() { @@ -154,38 +153,15 @@ func main() { mustBind(w, "importSubscription", a.importSubscription) mustBind(w, "saveRemnawave", a.saveRemnawave) mustBind(w, "importRemnawave", a.importRemnawave) - mustBind(w, "provisionUser", a.provisionUser) - mustBind(w, "renewAccount", a.renewAccount) mustBind(w, "setMode", a.setMode) mustBind(w, "getLogs", a.getLogs) go a.autoCheckUpdate() - go a.autoProvision() - mgr.StartAutoRenew(6 * time.Hour) w.SetHtml(appui.IndexHTML) w.Run() } -// autoProvision quietly issues a panel account + imports configs on first run -// (NordVPN-style centralized provisioning — users never edit the panel). -func (a *app) autoProvision() { - if !a.mgr.ProvisionConfigured() { - return - } - time.Sleep(2 * time.Second) - out, err := a.mgr.EnsureAutoProvision() - if err != nil { - fmt.Fprintf(a.logBuf, "auto-provision: %v\n", err) - return - } - if out.Created { - fmt.Fprintf(a.logBuf, "auto-provision: создан %s · серверов %d\n", out.Username, out.Imported) - } else if out.Imported > 0 { - fmt.Fprintf(a.logBuf, "auto-provision: обновлены конфиги %s · серверов %d\n", out.Username, out.Imported) - } -} - func mustBind(w webview2.WebView, name string, fn interface{}) { if err := w.Bind(name, fn); err != nil { fatalDialog("bind %s: %v", name, err) @@ -229,31 +205,29 @@ func (a *app) getState() (uiState, error) { corePath, coreReady = path, true } out := uiState{ - Connected: st.Connected, - Profile: st.Profile, - ActiveProfile: active, - Protocol: string(st.Protocol), - HTTPProxy: st.HTTPProxy, - SOCKSProxy: st.SOCKSProxy, - SystemProxy: cfg.SystemProxy, - Proxy: proxy, - CoreReady: coreReady, - CorePath: corePath, - ConfigPath: a.cfgPath, - Profiles: cfg.ListProfiles(), - Version: update.DisplayVersion(), - 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(), - ProvisionReady: a.mgr.ProvisionConfigured(), - Account: a.mgr.Account(), - Mode: a.mgr.Mode(), - VPNSupported: a.mgr.VPNModeSupported(), - VPNActive: st.VPNActive, + Connected: st.Connected, + Profile: st.Profile, + ActiveProfile: active, + Protocol: string(st.Protocol), + HTTPProxy: st.HTTPProxy, + SOCKSProxy: st.SOCKSProxy, + SystemProxy: cfg.SystemProxy, + Proxy: proxy, + CoreReady: coreReady, + CorePath: corePath, + ConfigPath: a.cfgPath, + Profiles: cfg.ListProfiles(), + Version: update.DisplayVersion(), + 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, } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -377,14 +351,6 @@ func (a *app) importRemnawave(s remnawave.Settings) (core.ImportResult, error) { return a.mgr.ImportRemnawave(s) } -func (a *app) provisionUser(username string) (core.ProvisionOutcome, error) { - return a.mgr.ProvisionAccess(username) -} - -func (a *app) renewAccount() (core.ProvisionOutcome, error) { - return a.mgr.RenewAccount() -} - func (a *app) setMode(mode string) error { return a.mgr.SetMode(strings.TrimSpace(mode)) } @@ -551,21 +517,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 { diff --git a/configs/remnawave-api.example.json b/configs/remnawave-api.example.json deleted file mode 100644 index 24cf6d3..0000000 --- a/configs/remnawave-api.example.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "panel_url": "https://panel.example.com", - "api_token": "PASTE_API_TOKEN_HERE", - "caddy_api_key": "", - "provision": { - "traffic_gb": 50, - "days": 30, - "strategy": "MONTH", - "hwid_device_limit": 0 - } -} diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 86f6375..1128258 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,16 +1,16 @@ { - "version": "4.0.0", - "notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.", + "version": "4.0.1", + "notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "os": "windows", "arch": "amd64" }, diff --git a/dist/navis-release/windows/EvilFox.exe b/dist/navis-release/windows/EvilFox.exe index 53b8ade..8025bcb 100644 Binary files a/dist/navis-release/windows/EvilFox.exe and b/dist/navis-release/windows/EvilFox.exe differ diff --git a/dist/navis-release/windows/Navis.exe b/dist/navis-release/windows/Navis.exe index 53b8ade..8025bcb 100644 Binary files a/dist/navis-release/windows/Navis.exe and b/dist/navis-release/windows/Navis.exe differ diff --git a/dist/navis-release/windows/evilfox-cli.exe b/dist/navis-release/windows/evilfox-cli.exe index 5ef29bc..383f669 100644 Binary files a/dist/navis-release/windows/evilfox-cli.exe and b/dist/navis-release/windows/evilfox-cli.exe differ diff --git a/dist/navis-release/windows/vpnclient.exe b/dist/navis-release/windows/vpnclient.exe index 5ef29bc..383f669 100644 Binary files a/dist/navis-release/windows/vpnclient.exe and b/dist/navis-release/windows/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index 86f6375..1128258 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,16 +1,16 @@ { - "version": "4.0.0", - "notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.", + "version": "4.0.1", + "notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "os": "windows", "arch": "amd64" }, diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 07f5c2c..7d183ce 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "net/url" "os" "strings" "sync" @@ -38,31 +39,29 @@ type App struct { } type UIState struct { - Connected bool `json:"connected"` - Profile string `json:"profile,omitempty"` - ActiveProfile string `json:"active_profile,omitempty"` - Protocol string `json:"protocol,omitempty"` - HTTPProxy string `json:"http_proxy,omitempty"` - SOCKSProxy string `json:"socks_proxy,omitempty"` - SystemProxy bool `json:"system_proxy"` - Proxy string `json:"proxy"` - CoreReady bool `json:"core_ready"` - CorePath string `json:"core_path,omitempty"` - ConfigPath string `json:"config_path"` - Profiles []config.ProfileInfo `json:"profiles"` - Version string `json:"version"` - 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"` - ProvisionReady bool `json:"provision_ready,omitempty"` - Account *remnawave.Account `json:"account,omitempty"` - Mode string `json:"mode"` - VPNSupported bool `json:"vpn_mode_supported"` - VPNActive bool `json:"vpn_active"` + Connected bool `json:"connected"` + Profile string `json:"profile,omitempty"` + ActiveProfile string `json:"active_profile,omitempty"` + Protocol string `json:"protocol,omitempty"` + HTTPProxy string `json:"http_proxy,omitempty"` + SOCKSProxy string `json:"socks_proxy,omitempty"` + SystemProxy bool `json:"system_proxy"` + Proxy string `json:"proxy"` + CoreReady bool `json:"core_ready"` + CorePath string `json:"core_path,omitempty"` + ConfigPath string `json:"config_path"` + Profiles []config.ProfileInfo `json:"profiles"` + Version string `json:"version"` + 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"` } type PingBestResult struct { @@ -74,20 +73,6 @@ type PingBestResult struct { } func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App { - // Monthly plan upkeep: check at start and then periodically. - mgr.StartAutoRenew(6 * time.Hour) - // NordVPN-style: silently issue a panel account + import configs. - go func() { - time.Sleep(2 * time.Second) - if !mgr.ProvisionConfigured() { - return - } - if out, err := mgr.EnsureAutoProvision(); err != nil { - fmt.Fprintf(logBuf, "auto-provision: %v\n", err) - } else if out.Created { - fmt.Fprintf(logBuf, "auto-provision: создан %s · серверов %d\n", out.Username, out.Imported) - } - }() return &App{ Mgr: mgr, CfgPath: cfgPath, @@ -136,31 +121,29 @@ func (a *App) GetState() (UIState, error) { corePath, coreReady = path, true } out := UIState{ - Connected: st.Connected, - Profile: st.Profile, - ActiveProfile: active, - Protocol: string(st.Protocol), - HTTPProxy: st.HTTPProxy, - SOCKSProxy: st.SOCKSProxy, - SystemProxy: cfg.SystemProxy, - Proxy: proxy, - CoreReady: coreReady, - CorePath: corePath, - ConfigPath: a.CfgPath, - Profiles: cfg.ListProfiles(), - Version: update.DisplayVersion(), - 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(), - ProvisionReady: a.Mgr.ProvisionConfigured(), - Account: a.Mgr.Account(), - Mode: a.Mgr.Mode(), - VPNSupported: a.Mgr.VPNModeSupported(), - VPNActive: st.VPNActive, + Connected: st.Connected, + Profile: st.Profile, + ActiveProfile: active, + Protocol: string(st.Protocol), + HTTPProxy: st.HTTPProxy, + SOCKSProxy: st.SOCKSProxy, + SystemProxy: cfg.SystemProxy, + Proxy: proxy, + CoreReady: coreReady, + CorePath: corePath, + ConfigPath: a.CfgPath, + Profiles: cfg.ListProfiles(), + Version: update.DisplayVersion(), + 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, } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -277,14 +260,6 @@ func (a *App) ImportRemnawave(s remnawave.Settings) (core.ImportResult, error) { return a.Mgr.ImportRemnawave(s) } -func (a *App) ProvisionUser(username string) (core.ProvisionOutcome, error) { - return a.Mgr.ProvisionAccess(username) -} - -func (a *App) RenewAccount() (core.ProvisionOutcome, error) { - return a.Mgr.RenewAccount() -} - func (a *App) SetMode(mode string) error { return a.Mgr.SetMode(strings.TrimSpace(mode)) } @@ -443,22 +418,42 @@ 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 button + «Рекомендуемые сервисы» block in index.html). +var allowedLinkHosts = map[string]bool{ + "evilfox.win": true, + "t.me": true, + "de4ima.uk": true, + "git.de4ima.uk": 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 не настроен") } @@ -560,10 +555,6 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { _ = json.Unmarshal(args[0], &s) } return a.ImportRemnawave(s) - case "provisionUser": - return a.ProvisionUser(arg(args, 0, "")) - case "renewAccount": - return a.RenewAccount() case "setMode": return nil, a.SetMode(arg(args, 0, "")) case "getLogs": diff --git a/internal/appui/index.html b/internal/appui/index.html index a48fbee..c06abae 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -473,7 +473,7 @@ .cur-server .ms.mid { color: var(--ms-mid); } .cur-server .ms.bad { color: var(--danger); } - /* Account / subscription cards */ + /* Subscription card */ .subinfo { border: 1px solid var(--line); border-radius: var(--radius); @@ -936,6 +936,43 @@ } .shop-link:hover { text-decoration: underline; } + /* «Рекомендуемые сервисы» — маленький ненавязчивый блок в углу «О программе» */ + .svc { + margin-top: 12px; + padding: 10px 12px 11px; + border-radius: 14px; + border: 1px dashed var(--line); + background: transparent; + } + .svc h4 { + margin: 0 0 7px; + font-family: Outfit, sans-serif; + font-size: .66rem; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--muted); + } + .svc-grid { display: flex; flex-wrap: wrap; gap: 6px; } + .svc-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: .74rem; + font-weight: 600; + cursor: pointer; + box-shadow: none; + transition: color .15s, border-color .15s; + } + .svc-link:hover { color: var(--accent-deep); border-color: rgba(13,138,102,.45); } + .svc-link .ic { font-size: .85rem; line-height: 1; } + .about-card { border: 1px solid var(--line); border-radius: var(--radius); @@ -1077,25 +1114,6 @@ - - - + +
+

Рекомендуемые сервисы

+
+
@@ -1327,7 +1351,7 @@ "getState","connect","disconnect","connectProfile","saveProfile","createProfile", "selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest", "checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave", - "provisionUser","renewAccount","setMode","getLogs","quit" + "setMode","getLogs","quit" ]; if (typeof window.getState === "function") return; window.__navisHttp = true; @@ -1378,8 +1402,6 @@ const storeUpdateNote = $("storeUpdateNote"); const themeToggle = $("themeToggle"); const themeSwitch = $("themeSwitch"); - const renewBtn = $("renewBtn"); - const accountBox = $("accountBox"); const modeProxyBtn = $("modeProxyBtn"); const modeVpnBtn = $("modeVpnBtn"); const modeSection = $("modeSection"); @@ -1464,6 +1486,15 @@ const protoChip = $("protoChip"); const heroHint = $("heroHint"); const SHOP_URL = "https://evilfox.win/"; + // Список рекомендуемых сервисов — замените ссылки/названия здесь. + // Хост каждой ссылки должен быть разрешён в allowlist openURL + // (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется. + const RECOMMENDED_SERVICES = [ + { icon: "🛒", name: "EvilFox Shop", url: "https://evilfox.win/" }, + { icon: "✈️", name: "Telegram", url: "https://t.me/evilfox" }, + { icon: "🖥️", name: "Хостинг de4ima", url: "https://de4ima.uk" }, + { icon: "🦊", name: "Git", url: "https://git.de4ima.uk/Evilfox/navi" } + ]; const AUTO_BEST_KEY = "navis.autoBest"; try { @@ -1736,44 +1767,6 @@ } } - function renderAccount(acc, subInfo) { - if (!accountBox) return; - if (!acc) { - accountBox.hidden = true; - return; - } - accountBox.hidden = false; - $("accName").textContent = acc.username || "—"; - - const expRow = $("accExpireRow"); - if (acc.expire_at) { - expRow.hidden = false; - const daysLeft = Math.floor((acc.expire_at * 1000 - Date.now()) / 86400000); - const el = $("accExpire"); - if (daysLeft < 0) { - el.textContent = fmtDate(acc.expire_at) + " · истёк"; - el.classList.add("warn"); - } else { - el.textContent = fmtDate(acc.expire_at) + " · осталось " + daysLeft + " дн."; - el.classList.toggle("warn", daysLeft <= 3); - } - } else { - expRow.hidden = true; - } - - const trafRow = $("accTrafficRow"); - const limit = acc.traffic_limit_bytes || 0; - const used = subInfo ? ((subInfo.upload || 0) + (subInfo.download || 0)) : 0; - if (limit > 0 || used > 0) { - trafRow.hidden = false; - $("accTraffic").textContent = limit > 0 - ? (fmtTraffic(used) + " из " + fmtTraffic(limit)) - : (fmtTraffic(used) + " · Безлимит"); - } else { - trafRow.hidden = true; - } - } - function renderMode(state) { currentMode = state.mode === "vpn" ? "vpn" : "proxy"; if (modeSection) { @@ -1894,7 +1887,6 @@ updateBtn.disabled = busy; subBtn.disabled = busy; subUrl.disabled = busy; - if (renewBtn) renewBtn.disabled = busy || !state.provision_ready; if (modeProxyBtn) modeProxyBtn.disabled = busy || connected; if (modeVpnBtn) modeVpnBtn.disabled = busy || connected; btn.disabled = busy; @@ -1903,7 +1895,6 @@ fillProfiles(state.profiles || [], state.active_profile || state.profile); renderUpdate(state.update, state.version); renderSubInfo(state.sub_info); - renderAccount(state.account, state.sub_info); renderMode(state); if (typeof state.subscription_url === "string" && !dirty) { subUrl.value = state.subscription_url; @@ -1975,7 +1966,7 @@ function paintButtonsLocked(locked) { [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, aboutUpdBtn, - updateBtn, skipUpdateBtn, subBtn, subUrl, renewBtn, modeProxyBtn, modeVpnBtn, logsRefreshBtn].forEach((b) => { + updateBtn, skipUpdateBtn, subBtn, subUrl, modeProxyBtn, modeVpnBtn, logsRefreshBtn].forEach((b) => { if (b) b.disabled = locked; }); } @@ -2079,6 +2070,34 @@ shopBtn.addEventListener("click", openShop); shopLink.addEventListener("click", openShop); + // «Рекомендуемые сервисы» — рендер из RECOMMENDED_SERVICES (см. константу выше). + (function renderRecommendedServices() { + const grid = $("svcGrid"); + if (!grid) return; + RECOMMENDED_SERVICES.forEach((s) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "svc-link"; + b.title = s.url; + const ic = document.createElement("span"); + ic.className = "ic"; + ic.textContent = s.icon || "•"; + const nm = document.createElement("span"); + nm.textContent = s.name; + b.append(ic, nm); + b.addEventListener("click", async () => { + try { + setMeta("Открываю " + s.name + "…"); + await openURL(s.url); + setMeta("Открыто в браузере", "ok"); + } catch (err) { + setMeta("Не удалось открыть ссылку: " + String(err), "err"); + } + }); + grid.appendChild(b); + }); + })(); + async function runImportSubscription() { const url = subUrl.value.trim(); if (!url) { @@ -2114,23 +2133,6 @@ } }); - if (renewBtn) { - renewBtn.addEventListener("click", () => withBusy(async () => { - try { - setMeta("Продление тарифа (50 ГБ / +30 дней)…"); - const r = await renewAccount(); - formHydrated = false; - dirty = false; - let msg = "Продлено: " + r.username; - if (r.traffic_limit_bytes > 0) msg += " · " + fmtTraffic(r.traffic_limit_bytes); - if (r.expire_at) { - try { msg += " · до " + new Date(r.expire_at * 1000).toLocaleDateString("ru-RU"); } catch (_) {} - } - setMeta(msg, "ok"); - } catch (e) { setMeta(String(e), "err"); } - })); - } - async function switchMode(mode) { if (mode === currentMode) return; await withBusy(async () => { diff --git a/internal/core/manager.go b/internal/core/manager.go index 338b472..f6be5fa 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -15,11 +15,11 @@ 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/netcheck" "vpnclient/internal/remnawave" "vpnclient/internal/subscription" "vpnclient/internal/sysproxy" @@ -37,12 +37,8 @@ type Manager struct { stderr io.Writer binDir string - // account is the locally remembered Remnawave user (configs/account.json). - account *remnawave.Account // vpn is the active VPN-mode (TUN) session, nil in proxy mode. vpn *vpnmode.Session - // renewMu serializes manual + automatic renewals. - renewMu sync.Mutex } func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) { @@ -53,18 +49,12 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, if err != nil { return nil, err } - acc, err := remnawave.LoadAccount(remnawave.AccountPath(cfgPath)) - if err != nil { - fmt.Fprintf(stderr, "account.json: %v\n", err) - acc = nil - } return &Manager{ cfgPath: cfgPath, cfg: cfg, sys: sysproxy.New(), stderr: stderr, binDir: binDir, - account: acc, }, nil } @@ -689,229 +679,6 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil } -// ProvisionOutcome is what the UI shows after issuing access via the panel. -type ProvisionOutcome struct { - Username string `json:"username"` - Created bool `json:"created"` - SubscriptionURL string `json:"subscription_url"` - TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"` - ExpireAt int64 `json:"expire_at,omitempty"` - Imported int `json:"imported"` - Skipped int `json:"skipped"` -} - -// ProvisionConfigured reports whether the Remnawave admin API is available -// (embedded credentials or configs/remnawave-api.json). Used for renew UI. -func (m *Manager) ProvisionConfigured() bool { - if remnawave.EmbeddedAdminConfig() != nil { - return true - } - st, err := os.Stat(remnawave.AdminConfigPath(m.cfgPath)) - return err == nil && !st.IsDir() -} - -// Account returns a copy of the remembered panel account (nil when absent). -func (m *Manager) Account() *remnawave.Account { - m.mu.Lock() - defer m.mu.Unlock() - if m.account == nil { - return nil - } - cp := *m.account - return &cp -} - -func (m *Manager) saveAccount(a *remnawave.Account) { - m.mu.Lock() - m.account = a - m.mu.Unlock() - if err := remnawave.SaveAccount(remnawave.AccountPath(m.cfgPath), a); err != nil { - fmt.Fprintf(m.stderr, "account.json: сохранение не удалось: %v\n", err) - } -} - -// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API, -// remembers it locally and imports its subscription configs into the app. -// Once an account is remembered, creating another one is refused — -// paid subscription flow will replace free provisioning later. -func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) { - if acc := m.Account(); acc != nil { - return ProvisionOutcome{}, fmt.Errorf("аккаунт уже привязан (%s) — новый создавать нельзя, используйте «Продлить»", acc.Username) - } - cfg, err := remnawave.ResolveAdminConfig(m.cfgPath) - if err != nil { - return ProvisionOutcome{}, err - } - return m.provisionWithConfig(cfg, username) -} - -// EnsureAutoProvision silently creates a panel user (if none remembered yet) -// and imports its subscription configs. NordVPN-style: panel address / API -// token are embedded; the user never edits them. Safe to call on every start. -func (m *Manager) EnsureAutoProvision() (ProvisionOutcome, error) { - if acc := m.Account(); acc != nil { - // Already provisioned — refresh configs in the background if empty. - if len(m.Profiles()) == 0 && acc.SubscriptionURL != "" { - if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil { - return ProvisionOutcome{ - Username: acc.Username, - SubscriptionURL: acc.SubscriptionURL, - TrafficLimitBytes: acc.TrafficLimitBytes, - ExpireAt: acc.ExpireAt, - Imported: imp.Imported, - Skipped: imp.Skipped, - }, nil - } - } - return ProvisionOutcome{ - Username: acc.Username, - SubscriptionURL: acc.SubscriptionURL, - TrafficLimitBytes: acc.TrafficLimitBytes, - ExpireAt: acc.ExpireAt, - }, nil - } - cfg, err := remnawave.ResolveAdminConfig(m.cfgPath) - if err != nil { - return ProvisionOutcome{}, err - } - return m.provisionWithConfig(cfg, remnawave.GenerateUsername()) -} - -func (m *Manager) provisionWithConfig(cfg *remnawave.AdminConfig, username string) (ProvisionOutcome, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - res, err := remnawave.ProvisionUser(ctx, cfg, username) - if err != nil { - return ProvisionOutcome{}, err - } - out := ProvisionOutcome{ - Username: res.Username, - Created: res.Created, - SubscriptionURL: res.SubscriptionURL, - TrafficLimitBytes: res.TrafficLimitBytes, - ExpireAt: res.ExpireAt, - } - subURL := res.SubscriptionURL - if subURL == "" && res.ShortUUID != "" { - subURL = remnawave.PublicSubURL(cfg.PanelURL, res.ShortUUID) - out.SubscriptionURL = subURL - } - if subURL == "" { - return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username) - } - - // Remember the account — from now on only renewals of this user. - m.saveAccount(&remnawave.Account{ - Username: res.Username, - UUID: res.UUID, - ShortUUID: res.ShortUUID, - SubscriptionURL: subURL, - TrafficLimitBytes: res.TrafficLimitBytes, - ExpireAt: res.ExpireAt, - CreatedAt: time.Now().Unix(), - }) - - imp, err := m.ImportSubscription(subURL) - if err != nil { - return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err) - } - out.Imported = imp.Imported - out.Skipped = imp.Skipped - return out, nil -} - -// RenewAccount extends the remembered user: resets traffic to the plan -// (50 GB default) and pushes expiry +30 days. Never creates users. -func (m *Manager) RenewAccount() (ProvisionOutcome, error) { - m.renewMu.Lock() - defer m.renewMu.Unlock() - - acc := m.Account() - if acc == nil { - return ProvisionOutcome{}, fmt.Errorf("нет привязанного аккаунта — сначала выдайте доступ") - } - cfg, err := remnawave.ResolveAdminConfig(m.cfgPath) - if err != nil { - return ProvisionOutcome{}, err - } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - res, err := remnawave.RenewUser(ctx, cfg, acc.UUID, acc.ExpireAt) - if err != nil { - return ProvisionOutcome{}, err - } - - acc.LastRenewedAt = time.Now().Unix() - if res.ExpireAt > 0 { - acc.ExpireAt = res.ExpireAt - } - if res.TrafficLimitBytes > 0 { - acc.TrafficLimitBytes = res.TrafficLimitBytes - } - if res.SubscriptionURL != "" { - acc.SubscriptionURL = res.SubscriptionURL - } - m.saveAccount(acc) - - out := ProvisionOutcome{ - Username: acc.Username, - SubscriptionURL: acc.SubscriptionURL, - TrafficLimitBytes: acc.TrafficLimitBytes, - ExpireAt: acc.ExpireAt, - } - // Refresh configs + usage info; renewal itself already succeeded. - if acc.SubscriptionURL != "" { - if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil { - out.Imported = imp.Imported - out.Skipped = imp.Skipped - } else { - fmt.Fprintf(m.stderr, "renew: обновление подписки после продления: %v\n", err) - } - } - fmt.Fprintf(m.stderr, "renew: аккаунт %s продлён до %s\n", acc.Username, - time.Unix(acc.ExpireAt, 0).Format("2006-01-02")) - return out, nil -} - -// StartAutoRenew launches the monthly auto-renew loop: checks on start and -// then every interval (6h default); renews when expiry is near/past or the -// traffic is exhausted. LastRenewedAt guards against renew loops. -func (m *Manager) StartAutoRenew(interval time.Duration) { - if interval <= 0 { - interval = 6 * time.Hour - } - go func() { - time.Sleep(15 * time.Second) // let the app start up first - for { - m.maybeAutoRenew() - time.Sleep(interval) - } - }() -} - -func (m *Manager) maybeAutoRenew() { - acc := m.Account() - if acc == nil || !m.ProvisionConfigured() { - return - } - now := time.Now() - // Anti-loop: never auto-renew more often than every 12 hours. - if acc.LastRenewedAt > 0 && now.Sub(time.Unix(acc.LastRenewedAt, 0)) < 12*time.Hour { - return - } - var used int64 - if si := m.SubscriptionInfo(); si != nil { - used = si.Upload + si.Download - } - if !acc.NeedsRenewal(used, now) { - return - } - fmt.Fprintf(m.stderr, "auto-renew: аккаунт %s требует продления (истекает/трафик исчерпан) — продлеваю…\n", acc.Username) - if _, err := m.RenewAccount(); err != nil { - fmt.Fprintf(m.stderr, "auto-renew: не удалось: %v\n", err) - } -} - func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo { if info == nil { return nil diff --git a/internal/core/manager_test.go b/internal/core/manager_test.go index 435b653..1d86c72 100644 --- a/internal/core/manager_test.go +++ b/internal/core/manager_test.go @@ -4,7 +4,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" "path/filepath" "strings" "testing" @@ -88,17 +87,3 @@ func TestSaveProfileKeepsNaiveLink(t *testing.T) { t.Fatalf("naive profile broken: %+v", p) } } - -func TestProvisionConfigured(t *testing.T) { - m := newTestManager(t) - if m.ProvisionConfigured() { - t.Fatal("should be false without remnawave-api.json") - } - path := filepath.Join(filepath.Dir(m.ConfigPath()), "remnawave-api.json") - if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil { - t.Fatal(err) - } - if !m.ProvisionConfigured() { - t.Fatal("should be true with remnawave-api.json present") - } -} diff --git a/internal/remnawave/account.go b/internal/remnawave/account.go deleted file mode 100644 index e36f837..0000000 --- a/internal/remnawave/account.go +++ /dev/null @@ -1,84 +0,0 @@ -package remnawave - -// Local account storage: after the first successful provisioning the client -// "remembers" the panel user in configs/account.json (never committed) and -// from then on only renews that same user — it never creates a second one. - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -// Account is the locally remembered panel user. -type Account struct { - Username string `json:"username"` - UUID string `json:"uuid"` - ShortUUID string `json:"short_uuid,omitempty"` - SubscriptionURL string `json:"subscription_url,omitempty"` - TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"` - ExpireAt int64 `json:"expire_at,omitempty"` // unix seconds; 0 = unknown - CreatedAt int64 `json:"created_at,omitempty"` // unix seconds - LastRenewedAt int64 `json:"last_renewed_at,omitempty"` // unix seconds; anti-loop guard - LastRenewCheckAt int64 `json:"last_renew_check,omitempty"` // unix seconds of last auto check -} - -// AccountPath returns configs/account.json next to the main config.json. -func AccountPath(cfgPath string) string { - return filepath.Join(filepath.Dir(cfgPath), "account.json") -} - -// LoadAccount reads the remembered account; returns (nil, nil) when absent. -func LoadAccount(path string) (*Account, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var a Account - if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &a); err != nil { - return nil, fmt.Errorf("account.json: некорректный JSON: %w", err) - } - if a.Username == "" && a.UUID == "" { - return nil, nil - } - return &a, nil -} - -// SaveAccount persists the account with restrictive permissions. -func SaveAccount(path string, a *Account) error { - if a == nil { - return fmt.Errorf("пустой аккаунт") - } - data, err := json.MarshalIndent(a, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - if dir := filepath.Dir(path); dir != "." && dir != "" { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - } - return os.WriteFile(path, data, 0o600) -} - -// NeedsRenewal reports whether the plan should be renewed now: -// expiry within 72h / past, or traffic exhausted (usedBytes vs limit). -func (a *Account) NeedsRenewal(usedBytes int64, now time.Time) bool { - if a == nil { - return false - } - if a.ExpireAt > 0 && time.Unix(a.ExpireAt, 0).Before(now.Add(72*time.Hour)) { - return true - } - if a.TrafficLimitBytes > 0 && usedBytes >= a.TrafficLimitBytes { - return true - } - return false -} diff --git a/internal/remnawave/embedded.go b/internal/remnawave/embedded.go deleted file mode 100644 index dd2b22a..0000000 --- a/internal/remnawave/embedded.go +++ /dev/null @@ -1,51 +0,0 @@ -package remnawave - -// Centralized (NordVPN-style) provisioning: the panel address and API token -// are baked into the binary at build time, so every user gets configs -// automatically and cannot change the panel. -// -// build.bat copies configs/remnawave-api.json into embeddedcfg/ before -// `go build`; the copy is gitignored so credentials never land in the repo. -// Without the embedded file the app falls back to the external -// configs/remnawave-api.json (developer mode). - -import ( - "crypto/rand" - "embed" - "encoding/hex" - "fmt" - "os" - "time" -) - -//go:embed embeddedcfg -var embeddedCfgFS embed.FS - -// EmbeddedAdminConfig returns the admin config baked into the binary, -// or nil when this build has no embedded credentials. -func EmbeddedAdminConfig() *AdminConfig { - data, err := embeddedCfgFS.ReadFile("embeddedcfg/remnawave-api.json") - if err != nil { - return nil - } - cfg, err := parseAdminConfig(data) - if err != nil { - return nil - } - return cfg -} - -// GenerateUsername makes a unique panel username for silent auto-provisioning -// (users never type anything): "fox_" + 10 hex chars, e.g. fox_3fa9c02b71. -func GenerateUsername() string { - var b [5]byte - if _, err := rand.Read(b[:]); err != nil { - // Keep within Remnawave's 6–34 char username limit. - s := fmt.Sprintf("fox_%x%x", os.Getpid()&0xffff, time.Now().UnixNano()&0xffffffff) - if len(s) > 34 { - s = s[:34] - } - return s - } - return "fox_" + hex.EncodeToString(b[:]) -} diff --git a/internal/remnawave/embeddedcfg/placeholder.json b/internal/remnawave/embeddedcfg/placeholder.json deleted file mode 100644 index d6ba415..0000000 --- a/internal/remnawave/embeddedcfg/placeholder.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "_comment": "build.bat overwrites remnawave-api.json here from configs/ before go build. This placeholder keeps the embed directory non-empty for go build without credentials." -} diff --git a/internal/remnawave/provision.go b/internal/remnawave/provision.go deleted file mode 100644 index 76a7f7f..0000000 --- a/internal/remnawave/provision.go +++ /dev/null @@ -1,344 +0,0 @@ -package remnawave - -// Provisioning: the app itself can issue access via the Remnawave admin API -// (docs: https://docs.rw / TypeScript SDK commands): -// -// GET {base}/api/users/by-username/{username} (GetUserByUsernameCommand) -// POST {base}/api/users (CreateUserCommand) -// -// Auth: Authorization: Bearer (+ optional Caddy X-Api-Key). -// Credentials live in a separate config file configs/remnawave-api.json — -// never committed; see configs/remnawave-api.example.json. - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -// ProvisionOptions are plan defaults for newly created users. -type ProvisionOptions struct { - TrafficGB int64 `json:"traffic_gb"` // default 50 - Days int `json:"days"` // default 30 - Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH) - HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited -} - -// AdminConfig is loaded from configs/remnawave-api.json (admin credentials). -type AdminConfig struct { - PanelURL string `json:"panel_url"` - APIToken string `json:"api_token"` - CaddyAPIKey string `json:"caddy_api_key,omitempty"` - Provision ProvisionOptions `json:"provision"` -} - -// AdminConfigPath returns the expected path of remnawave-api.json — -// next to the main config.json (configs dir). -func AdminConfigPath(cfgPath string) string { - return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json") -} - -// ResolveAdminConfig prefers the baked-in (embedded) panel credentials; -// falls back to configs/remnawave-api.json for local/developer builds. -func ResolveAdminConfig(cfgPath string) (*AdminConfig, error) { - if cfg := EmbeddedAdminConfig(); cfg != nil { - return cfg, nil - } - return LoadAdminConfig(AdminConfigPath(cfgPath)) -} - -// LoadAdminConfig reads and validates the admin API config file. -func LoadAdminConfig(path string) (*AdminConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path)) - } - return parseAdminConfig(data) -} - -// parseAdminConfig validates raw remnawave-api.json bytes (file or embedded). -func parseAdminConfig(data []byte) (*AdminConfig, error) { - var cfg AdminConfig - if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil { - return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err) - } - cfg.PanelURL = NormalizeBase(cfg.PanelURL) - cfg.APIToken = strings.TrimSpace(cfg.APIToken) - cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey) - if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") { - return nil, fmt.Errorf("remnawave-api.json: укажите panel_url") - } - if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" { - return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)") - } - // Plan defaults: 50 GB / 30 days / MONTH reset. - if cfg.Provision.TrafficGB <= 0 { - cfg.Provision.TrafficGB = 50 - } - if cfg.Provision.Days <= 0 { - cfg.Provision.Days = 30 - } - switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) { - case "NO_RESET", "DAY", "WEEK", "MONTH": - cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) - default: - cfg.Provision.Strategy = "MONTH" - } - if cfg.Provision.HwidDeviceLimit < 0 { - cfg.Provision.HwidDeviceLimit = 0 - } - return &cfg, nil -} - -// ProvisionResult describes the created or found panel user. -type ProvisionResult struct { - Username string `json:"username"` - UUID string `json:"uuid"` - ShortUUID string `json:"short_uuid"` - SubscriptionURL string `json:"subscription_url"` - TrafficLimitBytes int64 `json:"traffic_limit_bytes"` - ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown - Created bool `json:"created"` -} - -var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`) - -// NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.) -// into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars. -func NormalizeUsername(raw string) (string, error) { - s := strings.TrimSpace(raw) - s = strings.TrimPrefix(s, "@") - if i := strings.IndexByte(s, '@'); i > 0 { - s = s[:i] // email → local part - } - var b strings.Builder - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': - b.WriteRune(r) - case r == '.', r == ' ', r == '+': - b.WriteByte('_') - } - } - s = b.String() - for len(s) > 0 && len(s) < 6 { - s += "_vpn" // pad short handles ("ivan" → "ivan_vpn") - } - if len(s) > 34 { - s = s[:34] - } - if !reUsername.MatchString(s) { - return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s) - } - return s, nil -} - -// ProvisionUser finds an existing panel user by username or creates one with -// the configured plan (traffic_gb / days / strategy / hwid_device_limit), -// then returns its subscription URL for import. -func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) { - if cfg == nil { - return nil, fmt.Errorf("нет конфигурации remnawave-api") - } - username, err := NormalizeUsername(rawUsername) - if err != nil { - return nil, err - } - client := &http.Client{Timeout: 30 * time.Second} - - // 1) Existing user? - getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username) - status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil) - if err != nil { - return nil, err - } - switch { - case status == http.StatusOK: - res, err := parseProvisionUser(body) - if err != nil { - return nil, err - } - res.Username = username - return res, nil - case status == http.StatusNotFound: - // fall through to create - default: - return nil, adminHTTPError(status, body) - } - - // 2) Create with plan defaults. - payload := map[string]any{ - "username": username, - "status": "ACTIVE", - "trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024, - "trafficLimitStrategy": cfg.Provision.Strategy, - "expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339), - } - if cfg.Provision.HwidDeviceLimit > 0 { - payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit - } - raw, _ := json.Marshal(payload) - status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw) - if err != nil { - return nil, err - } - if status != http.StatusOK && status != http.StatusCreated { - return nil, adminHTTPError(status, body) - } - res, err := parseProvisionUser(body) - if err != nil { - return nil, err - } - res.Username = username - res.Created = true - return res, nil -} - -// RenewUser extends the SAME panel user: resets used traffic -// (POST /api/users/{uuid}/actions/reset-traffic) and pushes the plan forward -// via PATCH /api/users — trafficLimitBytes = plan (50 GB default) and -// expireAt = max(now, current expiry) + plan days (30 default). -// It never creates users. -func RenewUser(ctx context.Context, cfg *AdminConfig, userUUID string, currentExpire int64) (*ProvisionResult, error) { - if cfg == nil { - return nil, fmt.Errorf("нет конфигурации remnawave-api") - } - userUUID = strings.TrimSpace(userUUID) - if userUUID == "" { - return nil, fmt.Errorf("нет UUID пользователя для продления") - } - client := &http.Client{Timeout: 30 * time.Second} - - // 1) Reset used traffic. Some panels return 404 for this action route — - // tolerate it, the PATCH below still extends the plan. - resetURL := cfg.PanelURL + "/api/users/" + url.PathEscape(userUUID) + "/actions/reset-traffic" - status, body, err := adminDo(ctx, client, cfg, http.MethodPost, resetURL, nil) - if err != nil { - return nil, err - } - if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNotFound { - return nil, fmt.Errorf("сброс трафика: %w", adminHTTPError(status, body)) - } - - // 2) Extend expiry + (re)set the traffic limit. - base := time.Now().UTC() - if currentExpire > 0 { - if cur := time.Unix(currentExpire, 0).UTC(); cur.After(base) { - base = cur - } - } - payload := map[string]any{ - "uuid": userUUID, - "status": "ACTIVE", - "trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024, - "expireAt": base.AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339), - } - raw, _ := json.Marshal(payload) - status, body, err = adminDo(ctx, client, cfg, http.MethodPatch, cfg.PanelURL+"/api/users", raw) - if err != nil { - return nil, err - } - if status != http.StatusOK && status != http.StatusCreated { - return nil, fmt.Errorf("продление: %w", adminHTTPError(status, body)) - } - res, err := parseProvisionUser(body) - if err != nil { - return nil, err - } - return res, nil -} - -func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) { - var rd io.Reader - if body != nil { - rd = bytes.NewReader(body) - } - req, err := http.NewRequestWithContext(ctx, method, endpoint, rd) - if err != nil { - return 0, "", err - } - req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave provision)") - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("Authorization", "Bearer "+cfg.APIToken) - if cfg.CaddyAPIKey != "" { - req.Header.Set("X-Api-Key", cfg.CaddyAPIKey) - } - 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 0, "", fmt.Errorf("Remnawave API недоступен: %w", err) - } - defer resp.Body.Close() - raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if err != nil { - return resp.StatusCode, "", err - } - return resp.StatusCode, string(raw), nil -} - -func adminHTTPError(status int, body string) error { - msg := strings.TrimSpace(body) - if len(msg) > 160 { - msg = msg[:160] + "…" - } - switch status { - case http.StatusUnauthorized, http.StatusForbidden: - return fmt.Errorf("Неверный API ключ (HTTP %d) — проверьте api_token в remnawave-api.json", status) - case http.StatusConflict: - return fmt.Errorf("Пользователь уже существует, но панель вернула конфликт (HTTP 409): %s", msg) - case http.StatusBadRequest: - return fmt.Errorf("Панель отклонила запрос (HTTP 400): %s", msg) - default: - return fmt.Errorf("Remnawave HTTP %d: %s", status, msg) - } -} - -func parseProvisionUser(body string) (*ProvisionResult, error) { - var payload struct { - Response provisionUserDTO `json:"response"` - } - if err := json.Unmarshal([]byte(body), &payload); err != nil || payload.Response.UUID == "" { - // Some deployments return the DTO without the envelope. - var direct provisionUserDTO - if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil || direct.UUID == "" { - return nil, fmt.Errorf("Remnawave: не удалось разобрать ответ панели") - } - payload.Response = direct - } - u := payload.Response - res := &ProvisionResult{ - UUID: u.UUID, - ShortUUID: u.ShortUUID, - SubscriptionURL: strings.TrimSpace(u.SubscriptionURL), - TrafficLimitBytes: u.TrafficLimitBytes, - } - if u.ExpireAt != "" { - if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil { - res.ExpireAt = t.Unix() - } - } - return res, nil -} - -type provisionUserDTO struct { - UUID string `json:"uuid"` - ShortUUID string `json:"shortUuid"` - SubscriptionURL string `json:"subscriptionUrl"` - TrafficLimitBytes int64 `json:"trafficLimitBytes"` - ExpireAt string `json:"expireAt"` -} diff --git a/internal/remnawave/provision_test.go b/internal/remnawave/provision_test.go deleted file mode 100644 index 8cec1e4..0000000 --- a/internal/remnawave/provision_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package remnawave - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func testAdminConfig(baseURL string) *AdminConfig { - return &AdminConfig{ - PanelURL: baseURL, - APIToken: "test-token", - Provision: ProvisionOptions{ - TrafficGB: 50, - Days: 30, - Strategy: "MONTH", - }, - } -} - -func TestProvisionUserExisting(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer test-token" { - w.WriteHeader(http.StatusUnauthorized) - return - } - if r.Method == http.MethodGet && r.URL.Path == "/api/users/by-username/ivan_petrov" { - _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{ - "uuid": "u-1", - "shortUuid": "short1", - "subscriptionUrl": "https://sub.example.com/short1", - "trafficLimitBytes": int64(50) << 30, - "expireAt": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), - }}) - return - } - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "ivan_petrov") - if err != nil { - t.Fatal(err) - } - if res.Created { - t.Fatal("expected existing user, got created") - } - if res.SubscriptionURL != "https://sub.example.com/short1" { - t.Fatalf("subscription url: %q", res.SubscriptionURL) - } - if res.Username != "ivan_petrov" { - t.Fatalf("username: %q", res.Username) - } -} - -func TestProvisionUserCreate(t *testing.T) { - var created map[string]any - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/users/by-username/"): - w.WriteHeader(http.StatusNotFound) - case r.Method == http.MethodPost && r.URL.Path == "/api/users": - if err := json.NewDecoder(r.Body).Decode(&created); err != nil { - t.Errorf("decode create body: %v", err) - } - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{ - "uuid": "u-2", - "shortUuid": "short2", - "subscriptionUrl": "https://sub.example.com/short2", - "trafficLimitBytes": created["trafficLimitBytes"], - "expireAt": created["expireAt"], - }}) - default: - t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) - w.WriteHeader(http.StatusBadRequest) - } - })) - defer ts.Close() - - res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "@new_client") - if err != nil { - t.Fatal(err) - } - if !res.Created { - t.Fatal("expected created user") - } - if res.Username != "new_client" { - t.Fatalf("username: %q", res.Username) - } - if got := created["trafficLimitBytes"].(float64); int64(got) != 50<<30 { - t.Fatalf("trafficLimitBytes = %v, want %d (50 GB)", got, int64(50)<<30) - } - if created["trafficLimitStrategy"] != "MONTH" { - t.Fatalf("strategy = %v", created["trafficLimitStrategy"]) - } - if created["status"] != "ACTIVE" { - t.Fatalf("status = %v", created["status"]) - } - expireStr, _ := created["expireAt"].(string) - expire, err := time.Parse(time.RFC3339, expireStr) - if err != nil { - t.Fatalf("expireAt %q: %v", expireStr, err) - } - days := time.Until(expire).Hours() / 24 - if days < 29 || days > 31 { - t.Fatalf("expireAt %.1f days from now, want ~30", days) - } -} - -func TestProvisionUserBadToken(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer ts.Close() - - _, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "somebody1") - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "Неверный API ключ") { - t.Fatalf("error = %v, want «Неверный API ключ»", err) - } -} - -func TestNormalizeUsername(t *testing.T) { - cases := []struct { - in, want string - wantErr bool - }{ - {"ivan_petrov", "ivan_petrov", false}, - {"@tg_handle", "tg_handle", false}, - {"user@example.com", "user_vpn", false}, - {"Иван", "", true}, - {"ab", "ab_vpn", false}, // padded to min length - } - for _, c := range cases { - got, err := NormalizeUsername(c.in) - if c.wantErr { - if err == nil { - t.Fatalf("%q: expected error, got %q", c.in, got) - } - continue - } - if err != nil { - t.Fatalf("%q: %v", c.in, err) - } - if got != c.want { - t.Fatalf("%q: got %q want %q", c.in, got, c.want) - } - } -} - -func TestLoadAdminConfig(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "remnawave-api.json") - - if _, err := LoadAdminConfig(path); err == nil { - t.Fatal("expected error for missing file") - } - - body := `{"panel_url":"https://panel.test/","api_token":"tok123","provision":{}}` - if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - t.Fatal(err) - } - cfg, err := LoadAdminConfig(path) - if err != nil { - t.Fatal(err) - } - if cfg.PanelURL != "https://panel.test" { - t.Fatalf("panel url: %q", cfg.PanelURL) - } - if cfg.Provision.TrafficGB != 50 || cfg.Provision.Days != 30 || cfg.Provision.Strategy != "MONTH" { - t.Fatalf("defaults not applied: %+v", cfg.Provision) - } - - // Placeholder token must be rejected. - body = `{"panel_url":"https://panel.test","api_token":"PASTE_API_TOKEN_HERE"}` - if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - t.Fatal(err) - } - if _, err := LoadAdminConfig(path); err == nil { - t.Fatal("expected error for placeholder token") - } -} diff --git a/internal/update/update.go b/internal/update/update.go index ec14145..7770161 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -18,7 +18,7 @@ import ( // CurrentVersion is the product/semver used for update eligibility (feed "version"). // Keep major.minor.patch only — no build suffix here. -const CurrentVersion = "4.0.0" +const CurrentVersion = "4.0.1" // BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part, // macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build. diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml index aa1107a..528278d 100644 --- a/packaging/msix/AppxManifest.xml +++ b/packaging/msix/AppxManifest.xml @@ -33,7 +33,7 @@ diff --git a/scripts/pack-msix.ps1 b/scripts/pack-msix.ps1 index 86f0a19..849ed6b 100644 --- a/scripts/pack-msix.ps1 +++ b/scripts/pack-msix.ps1 @@ -9,13 +9,13 @@ .EXAMPLE .\scripts\pack-msix.ps1 - .\scripts\pack-msix.ps1 -Version 4.0.0.1 -Publisher "CN=EvilFox" + .\scripts\pack-msix.ps1 -Version 4.0.1.1 -Publisher "CN=EvilFox" #> [CmdletBinding()] param( [string]$ExePath = "", [string]$OutDir = "", - [string]$Version = "4.0.0.1", + [string]$Version = "4.0.1.1", [string]$Name = "EvilFox.EvilFox", [string]$Publisher = "CN=EvilFox", [string]$DisplayName = "EvilFox", @@ -82,7 +82,7 @@ $manifestText = Get-Content -LiteralPath $ManifestSrc -Raw -Encoding UTF8 # Prefer exact placeholder swaps so we never touch . $manifestText = $manifestText.Replace('Name="EvilFox.EvilFox"', "Name=`"$Name`"") $manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"") -$manifestText = $manifestText.Replace('Version="4.0.0.1"', "Version=`"$Version`"") +$manifestText = $manifestText.Replace('Version="4.0.1.1"', "Version=`"$Version`"") $manifestText = $manifestText.Replace('EvilFox', "$DisplayName") $manifestText = $manifestText.Replace('EvilFox', "$PublisherDisplayName") $manifestText = $manifestText.Replace('DisplayName="EvilFox"', "DisplayName=`"$DisplayName`"") diff --git a/server/update.json b/server/update.json index 86f6375..1128258 100644 --- a/server/update.json +++ b/server/update.json @@ -1,16 +1,16 @@ { - "version": "4.0.0", - "notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.", + "version": "4.0.1", + "notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe", - "sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c", + "sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939", "os": "windows", "arch": "amd64" }, diff --git a/versioninfo.json b/versioninfo.json index 27ecaf5..fb1e0d3 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 4, "Minor": 0, "Patch": 0, "Build": 1 }, - "ProductVersion": { "Major": 4, "Minor": 0, "Patch": 0, "Build": 1 }, + "FileVersion": { "Major": 4, "Minor": 0, "Patch": 1, "Build": 1 }, + "ProductVersion": { "Major": 4, "Minor": 0, "Patch": 1, "Build": 1 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -11,12 +11,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "4.0.0.1", + "FileVersion": "4.0.1.1", "InternalName": "EvilFox", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "EvilFox.exe", "ProductName": "EvilFox", - "ProductVersion": "4.0.0.1", + "ProductVersion": "4.0.1.1", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {