Author SHA1 Message Date
NavisandCursor c370e089ef Fix 4.2.7+1: restore UTF-8 in update.go (em-dash/RU strings) and rebuild Windows binaries + feed SHA.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:52:05 +03:00
NavisandCursor fa3e11216e Release 4.2.7+1: adaptive HiDPI window sizing (work-area DIPs + min size) and responsive CSS so the UI opens usable without stretch. Windows 4.2.7.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:00:10 +03:00
NavisandCursor a1756715ef Release 4.2.6+1: fix sidebar RU/EN and theme toggles — applyTheme param shadowed i18n t(), so init threw and click listeners never bound. Windows 4.2.6.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:21:52 +03:00
NavisandCursor e633b63a9b Release 4.2.5+1: RU/EN language switch in UI (navis.lang, default Russian); translate static and dynamic strings including server titles and country names. Windows 4.2.5.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:07:12 +03:00
NavisandCursor 6d52913c32 Release 4.2.4+1: group same-host profiles into one server row with protocol chips (VLESS · Trojan · Hysteria2) instead of «сервер 2/3»; оптимальный among distinct hosts. Windows 4.2.4.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:40:17 +03:00
NavisandCursor 0ba662c650 Release 4.2.3+1: на вкладке «Защита» только карточка EvilFox — убраны NordVPN/ExpressVPN/Surfshark и их openURL-хосты. Windows 4.2.3.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:15:48 +03:00
NavisandCursor 19711c3a33 Release 4.2.2+1: светлая тема по умолчанию (без flash dark), Telegram поддержки → t.me/evilfox100Bot. Windows 4.2.2.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:32:09 +03:00
NavisandCursor 497af0e475 Release 4.2.1+1: UI polish — опечатки Румынии→RO+флаг, чистая шапка «Серверы» без +/×, «Полезные ссылки» (сайт+Telegram), убран мусор «СПЕЦИАЛЬНЫЕ СЕРВИСИ», sticky кнопка ПОДКЛЮЧИТЬСЯ, свойства сервера заполнены, без T/V в списке, оффер «3 дня бесплатного доступа». Windows 4.2.1.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:08:28 +03:00
NavisandCursor 2a57987aea Release 4.2.0+1: store-compliance формулировки и UX — убраны все цены/продажи из приложения (оффер «3 дня бесплатно» без ₽), «Активировать ключ» → «Добавить конфигурацию» (URL-ссылка, placeholder https://), блок «Ключ доступа» заменён статусом «Конфигурация добавлена/не добавлена» (URL не отображается), глобально Подписка→Конфигурация/Ваучер→URL-ссылка/Тариф→Профиль в UI и Go-строках; большая круглая кнопка ПОДКЛЮЧИТЬСЯ/ОТКЛЮЧИТЬСЯ со статусом, убран футер «Готово к подключению», протокол в списке — буквенный бейдж только при отличии от активного, трафик «ДОСТУПНО X ГБ» + «Активен до DD.MM.YYYY» + процент; пинг отдельного сервера кнопкой в строке, экран Статистика (за сегодня: трафик/время в сети/сессии + график 7 дней из localStorage), Kill Switch для VPN-режима (blackhole-маршруты сохраняются при обрыве туннеля, для прокси — системный прокси остаётся на мёртвый порт), тумблер «Авто-лучший сервер» в Настройках. Windows 4.2.0.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 22:34:43 +03:00
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
NavisandCursor 01fac376ba Release 3.10.0+1: sidebar UI (Home / Profiles / Settings / Logs / About), remembered account with 50 GB monthly renew + auto-renew, Windows VPN mode (wintun + tun2socks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 18:34:25 +03:00
NavisandCursor 77bd7da861 Release 3.9.0+3: subscription-URL routing, emoji flags, Remnawave provisioning.
Route credential-less http(s) URLs pasted into the add-link field to
subscription import (fixes remaining 'proxy URI missing username').
Extend geoflag with RU country names and city hints; live Remnawave
names already carrying emoji flags are kept as-is. Add admin
provisioning via configs/remnawave-api.json (GET by-username / POST
users, 50 GB MONTH plan) and the «Выдать доступ» UI panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:36:54 +03:00
NavisandCursor e34312ef9c Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:01:37 +03:00
NavisandCursor 015ded9bd5 Release 3.9.0+1: Remnawave API configs, Remnawave-style UI with flags, Windows build.
EOF

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