Author SHA1 Message Date
NavisandCursor 74ffbd87d7 Release 4.1.2+1: настоящие SVG-флаги стран — Windows/WebView2 не отображает эмодзи-флаги (Segoe UI Emoji рисует пары региональных индикаторов буквами «BE», «LV»), поэтому встроен офлайн-генератор SVG-флагов (~75 стран: полосы/кресты + упрощённые сложные флаги GB/US/CH/KR и др., ~6 КБ в index.html); флаги в списке серверов и на карточке активного сервера, буквенный код — только для неизвестных регионов, обе темы. Windows 4.1.2.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 22:04:18 +03:00
NavisandCursor 46e28aa73f Release 4.1.1+1: UI polish по чек-листу ревью — трафик «Доступно X ГБ» + процент использования (баг «Осталось 100 из 100 · 0%»), оффер «3 дня бесплатно, затем от 99 ₽/мес», бейдж «Рекомендуем» на карточке EvilFox + подпись «Сторонний сервис» у конкурентов, пилюля «АКТИВЕН» и протокол мелким текстом в списке серверов, «—» вместо пустого пинга, счётчик серверов только на «Мои серверы», 🦊 вместо буквы N (старый логотип Navis), флаги во всех строках, geoflag-алиасы опечаток («Румьиня» → RO), фикс [hidden] vs display:grid. Windows 4.1.1.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 21:39:37 +03:00
orohimaru2andCursor 329209fa88 Release 4.1.0+1: Store-compliance UI rework — tabs Защита/Мои серверы/Настройки/Статистика/Помощь; provider chooser (EvilFox highlighted + NordVPN/ExpressVPN/Surfshark, clean links); «Активировать ключ» wording; human-readable server names (host:port in server properties only); proxy ports moved to advanced settings/stats; traffic progress bar on main; account info on Мои серверы; connect spinner; ping test buttons. Windows 4.1.0.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:55:40 +03:00
NavisandCursor 5f768c7732 Release 4.0.1+1: remove account binding / centralized provisioning (no panel credentials in binary; configs via user's own subscription URL); add recommended-services block on About page; Windows 4.0.1.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:31:29 +03:00
Navis 1144fab54d Release 4.0.0+1: rebrand Navis → EvilFox; centralized Remnawave auto-provisioning (panel credentials baked into binary); remove Remnawave API UI; Windows 4.0.0.1.
Keep dist/navis-release/ path and ship Navis.exe as a same-hash alias of EvilFox.exe so 3.x clients can still update from the Windows branch feed.
2026-08-01 20:06:25 +03:00
NavisandCursor e76c0a0977 Release 3.10.0+2: switch update feed and asset URLs to git.de4ima.uk/Evilfox/navi (branch Windows); Windows 3.10.0.2 rebuild with new embedded feed URL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 19:36:19 +03:00
37 changed files with 1175 additions and 1456 deletions
+3
View File
@@ -17,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/
+5 -26
View File
@@ -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-подпиской / **Remnawave API** и автообновлением.
Клиент [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) |
@@ -230,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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3_100_001
versionName = "3.10.0+1"
versionCode = 4_010_201
versionName = "4.1.2+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)
+2 -2
View File
@@ -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>
+13 -8
View File
@@ -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
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
+55 -78
View File
@@ -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() {
@@ -117,7 +116,7 @@ func main() {
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis 2",
Title: "EvilFox",
Width: 920,
Height: 680,
Center: true,
@@ -154,13 +153,10 @@ 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()
mgr.StartAutoRenew(6 * time.Hour)
w.SetHtml(appui.IndexHTML)
w.Run()
@@ -209,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 {
@@ -357,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))
}
@@ -503,12 +489,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() {
@@ -531,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 {
+1 -1
View File
@@ -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
}
+8 -8
View File
@@ -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
-11
View File
@@ -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
}
}
+16 -16
View File
@@ -1,45 +1,45 @@
{
"version": "3.10.0",
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
"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.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"
"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-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
"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.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"
"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-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
"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.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"
"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"
},
"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"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -16
View File
@@ -1,45 +1,45 @@
{
"version": "3.10.0",
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
"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.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"
"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-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
"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.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"
"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-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
"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.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"
"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"
},
"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"
+83 -76
View File
@@ -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,8 +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)
return &App{
Mgr: mgr,
CfgPath: cfgPath,
@@ -124,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 {
@@ -265,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))
}
@@ -410,7 +397,7 @@ func (a *App) ApplyUpdate() (string, error) {
os.Exit(0)
}()
}
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
return "Устанавливается v" + latest + "… EvilFox перезапустится.", nil
}
func (a *App) AutoCheckUpdate() {
@@ -431,22 +418,46 @@ 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, «Рекомендуемые сервисы» and provider cards in index.html).
// Competitor links are plain URLs without referral codes (Store policy).
var allowedLinkHosts = map[string]bool{
"evilfox.win": true,
"t.me": true,
"de4ima.uk": true,
"git.de4ima.uk": true,
"nordvpn.com": true,
"expressvpn.com": true,
"surfshark.com": 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 не настроен")
}
@@ -548,10 +559,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":
+881 -323
View File
File diff suppressed because it is too large Load Diff
+3 -197
View File
@@ -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
}
@@ -298,10 +288,10 @@ func (m *Manager) SetMode(mode string) error {
return fmt.Errorf("режим VPN пока доступен только на Windows")
}
if !vpnmode.IsElevated() {
return fmt.Errorf("Запустите Navis от имени администратора для режима VPN")
return fmt.Errorf("Запустите EvilFox от имени администратора для режима VPN")
}
if !vpnmode.WintunAvailable() {
return fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с Navis.exe")
return fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с EvilFox.exe")
}
}
m.mu.Lock()
@@ -689,190 +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 configs/remnawave-api.json exists —
// the admin "Выдать доступ" panel is shown only in that case.
func (m *Manager) ProvisionConfigured() bool {
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.LoadAdminConfig(remnawave.AdminConfigPath(m.cfgPath))
if err != nil {
return ProvisionOutcome{}, err
}
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.LoadAdminConfig(remnawave.AdminConfigPath(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
-15
View File
@@ -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")
}
}
+3
View File
@@ -140,6 +140,9 @@ var countryNames = []nameCode{
{"исландия", "IS"}, {"люксембург", "LU"}, {"англия", "GB"},
{"сша", "US"}, {"америка", "US"},
// Частые опечатки в именах серверов на панелях (наблюдались в проде).
{"румьиня", "RO"}, {"румыня", "RO"}, {"руминия", "RO"},
// City → country hints (EN + RU).
{"frankfurt", "DE"}, {"amsterdam", "NL"}, {"london", "GB"},
{"paris", "FR"}, {"helsinki", "FI"}, {"stockholm", "SE"},
+3
View File
@@ -25,6 +25,9 @@ func TestFromText(t *testing.T) {
{"🇨🇿 Czech Republic Vless", "CZ"},
{"🇳🇴 Norway TR+WS", "NO"},
{"🇩🇪 Germany 3 HY", "DE"},
// Panel-side typos seen in production.
{"Румьиня", "RO"},
{"Румыня-2", "RO"},
}
for _, c := range cases {
cc, emoji := FromText(c.in)
-84
View File
@@ -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
}
+1 -1
View File
@@ -183,7 +183,7 @@ func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string
if err != nil {
return "", nil, err
}
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)")
req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave)")
req.Header.Set("Accept", "*/*")
if auth && s.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.Token)
-330
View File
@@ -1,330 +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 <admin API token> (+ 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")
}
// 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))
}
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", "Navis/3.10 (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"`
}
-192
View File
@@ -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")
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ var (
// 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{
"Navis/3.9 (+https://evilfox.win)",
"EvilFox/4.0 (+https://evilfox.win)",
"ClashMeta/1.18.0",
}
+7 -6
View File
@@ -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.10.0"
const CurrentVersion = "4.1.2"
// 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 {
+7 -3
View File
@@ -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 {
+2 -2
View File
@@ -86,10 +86,10 @@ func Start(cfg Config) (*Session, error) {
return nil, fmt.Errorf("режим VPN: нет локального SOCKS-прокси у текущего протокола")
}
if !IsElevated() {
return nil, fmt.Errorf("Запустите Navis от имени администратора для режима VPN")
return nil, fmt.Errorf("Запустите EvilFox от имени администратора для режима VPN")
}
if !WintunAvailable() {
return nil, fmt.Errorf("не найден wintun.dll — скачайте с wintun.net и положите рядом с Navis.exe")
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"}
+12 -12
View File
@@ -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.10.0.1"
Version="4.1.2.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>
+15 -15
View File
@@ -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.9.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 = "3.9.0.3",
[string]$Name = "EvilFox.Navis",
[string]$Version = "4.1.2.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,22 +74,22 @@ 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.9.0.3"', "Version=`"$Version`"")
$manifestText = $manifestText.Replace('<DisplayName>Navis</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
$manifestText = $manifestText.Replace('Version="4.1.2.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) {
+16 -16
View File
@@ -1,45 +1,45 @@
{
"version": "3.10.0",
"notes": "3.10.0+1: новый интерфейс с боковым меню (Главная / Профили / Настройки / Логи / О программе), аккаунт с продлением 50 ГБ / +30 дней и автопродлением, режим VPN на Windows (TUN, весь трафик, нужны права администратора и wintun.dll). Windows 3.10.0.1.",
"version": "4.1.2",
"notes": "4.1.2: настоящие флаги стран (SVG) — на Windows эмодзи-флаги не отображаются",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
"sha256": "2d1face047cf2fa8e6479c2e078495fff6233c28baa4d4c7cab0cef39d065d30",
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
"sha256": "af18f836ec6607cc151ba373d2186548ba5d2033f4366445264285e92d370bee",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/arm64/Navis",
"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.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"
"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-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/amd64/Navis",
"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.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"
"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-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/macos/universal/Navis",
"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.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"
"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"
},
"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"
+7 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net/http"
"os"
"vpnclient/internal/apphost"
"vpnclient/internal/config"
@@ -28,6 +29,10 @@ func main() {
log.Fatal(err)
}
a := apphost.New(mgr, cfgPath, logBuf)
fmt.Println("preview on http://127.0.0.1:8765/")
log.Fatal(http.ListenAndServe("127.0.0.1:8765", a.Handler()))
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
View File
@@ -1,7 +1,7 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 3, "Minor": 10, "Patch": 0, "Build": 1 },
"ProductVersion": { "Major": 3, "Minor": 10, "Patch": 0, "Build": 1 },
"FileVersion": { "Major": 4, "Minor": 1, "Patch": 2, "Build": 1 },
"ProductVersion": { "Major": 4, "Minor": 1, "Patch": 2, "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.10.0.1",
"InternalName": "Navis",
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "4.1.2.1",
"InternalName": "EvilFox",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
"ProductVersion": "3.10.0.1",
"OriginalFilename": "EvilFox.exe",
"ProductName": "EvilFox",
"ProductVersion": "4.1.2.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {