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>
This commit is contained in:
@@ -6,8 +6,6 @@ bin/
|
|||||||
configs/config.json
|
configs/config.json
|
||||||
configs/remnawave-api.json
|
configs/remnawave-api.json
|
||||||
configs/account.json
|
configs/account.json
|
||||||
# Baked panel credentials (copied by build.bat; never commit)
|
|
||||||
internal/remnawave/embeddedcfg/remnawave-api.json
|
|
||||||
dist/navis-release/windows/configs/
|
dist/navis-release/windows/configs/
|
||||||
# Runtime-downloaded protocol cores + local packaging artifact (not release sources)
|
# Runtime-downloaded protocol cores + local packaging artifact (not release sources)
|
||||||
dist/navis-release/windows/bin/
|
dist/navis-release/windows/bin/
|
||||||
|
|||||||
@@ -232,29 +232,6 @@ build.bat
|
|||||||
|
|
||||||
Токены хранятся только локально в `configs/config.json` (не коммитьте).
|
Токены хранятся только локально в `configs/config.json` (не коммитьте).
|
||||||
|
|
||||||
### Выдача доступа из приложения (admin API)
|
|
||||||
|
|
||||||
Приложение умеет само создавать пользователей в панели Remnawave и сразу импортировать их конфиги (панель «Выдать доступ» в интерфейсе). Для этого нужен **отдельный** файл с админ-ключом:
|
|
||||||
|
|
||||||
1. Скопируйте `configs/remnawave-api.example.json` → `configs/remnawave-api.json`.
|
|
||||||
2. Вставьте `panel_url` и `api_token` (панель → API Tokens). `caddy_api_key` — только если панель за Caddy-auth.
|
|
||||||
3. Перезапустите Navis — появится панель «Выдать доступ».
|
|
||||||
|
|
||||||
Файл читается **в рантайме** из папки `configs` рядом с `config.json` — пересборка (repack) не требуется, достаточно положить файл и перезапустить приложение.
|
|
||||||
|
|
||||||
Тариф по умолчанию (настраивается в блоке `provision`):
|
|
||||||
|
|
||||||
| Поле | Значение по умолчанию |
|
|
||||||
|------|----------------------|
|
|
||||||
| `traffic_gb` | 50 ГБ |
|
|
||||||
| `days` | 30 дней (expireAt = сейчас + days) |
|
|
||||||
| `strategy` | `MONTH` (сброс трафика; также `NO_RESET` / `DAY` / `WEEK`) |
|
|
||||||
| `hwid_device_limit` | 0 = без лимита устройств |
|
|
||||||
|
|
||||||
Логика: `GET /api/users/by-username/{username}` — если пользователь есть, берётся его `subscriptionUrl`; иначе `POST /api/users` с тарифом выше, затем подписка импортируется в приложение автоматически.
|
|
||||||
|
|
||||||
`configs/remnawave-api.json` в `.gitignore` — **никогда не коммитьте** реальный ключ, в репозитории только `remnawave-api.example.json`.
|
|
||||||
|
|
||||||
## Сборка macOS (кросс с Windows / Linux)
|
## Сборка macOS (кросс с Windows / Linux)
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||||
versionCode = 4_000_001
|
versionCode = 4_000_101
|
||||||
versionName = "4.0.0+1"
|
versionName = "4.0.1+1"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||||
|
|||||||
@@ -14,16 +14,6 @@ if errorlevel 1 (
|
|||||||
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
|
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
REM Bake Remnawave panel credentials into the binary (NordVPN-style centralized provisioning).
|
|
||||||
REM Source of truth: configs\remnawave-api.json (gitignored). Never commit the embedded copy.
|
|
||||||
if exist "configs\remnawave-api.json" (
|
|
||||||
mkdir "internal\remnawave\embeddedcfg" 2>nul
|
|
||||||
copy /Y "configs\remnawave-api.json" "internal\remnawave\embeddedcfg\remnawave-api.json" >nul
|
|
||||||
echo Embedded panel credentials from configs\remnawave-api.json
|
|
||||||
) else (
|
|
||||||
echo WARNING: configs\remnawave-api.json missing — build will use external file / no auto-provision
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Building EvilFox GUI and CLI...
|
echo Building EvilFox GUI and CLI...
|
||||||
go build -ldflags="-H windowsgui -s -w" -trimpath -o EvilFox.exe ./cmd/vpnapp
|
go build -ldflags="-H windowsgui -s -w" -trimpath -o EvilFox.exe ./cmd/vpnapp
|
||||||
if errorlevel 1 exit /b 1
|
if errorlevel 1 exit /b 1
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/jchv/go-webview2"
|
"github.com/jchv/go-webview2"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
"vpnclient/internal/apphost"
|
||||||
"vpnclient/internal/appui"
|
"vpnclient/internal/appui"
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/core"
|
"vpnclient/internal/core"
|
||||||
@@ -60,8 +61,6 @@ type uiState struct {
|
|||||||
Remnawave remnawave.Settings `json:"remnawave"`
|
Remnawave remnawave.Settings `json:"remnawave"`
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
ProvisionReady bool `json:"provision_ready,omitempty"`
|
|
||||||
Account *remnawave.Account `json:"account,omitempty"`
|
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
VPNSupported bool `json:"vpn_mode_supported"`
|
VPNSupported bool `json:"vpn_mode_supported"`
|
||||||
VPNActive bool `json:"vpn_active"`
|
VPNActive bool `json:"vpn_active"`
|
||||||
@@ -154,38 +153,15 @@ func main() {
|
|||||||
mustBind(w, "importSubscription", a.importSubscription)
|
mustBind(w, "importSubscription", a.importSubscription)
|
||||||
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
||||||
mustBind(w, "importRemnawave", a.importRemnawave)
|
mustBind(w, "importRemnawave", a.importRemnawave)
|
||||||
mustBind(w, "provisionUser", a.provisionUser)
|
|
||||||
mustBind(w, "renewAccount", a.renewAccount)
|
|
||||||
mustBind(w, "setMode", a.setMode)
|
mustBind(w, "setMode", a.setMode)
|
||||||
mustBind(w, "getLogs", a.getLogs)
|
mustBind(w, "getLogs", a.getLogs)
|
||||||
|
|
||||||
go a.autoCheckUpdate()
|
go a.autoCheckUpdate()
|
||||||
go a.autoProvision()
|
|
||||||
mgr.StartAutoRenew(6 * time.Hour)
|
|
||||||
|
|
||||||
w.SetHtml(appui.IndexHTML)
|
w.SetHtml(appui.IndexHTML)
|
||||||
w.Run()
|
w.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoProvision quietly issues a panel account + imports configs on first run
|
|
||||||
// (NordVPN-style centralized provisioning — users never edit the panel).
|
|
||||||
func (a *app) autoProvision() {
|
|
||||||
if !a.mgr.ProvisionConfigured() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
out, err := a.mgr.EnsureAutoProvision()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(a.logBuf, "auto-provision: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if out.Created {
|
|
||||||
fmt.Fprintf(a.logBuf, "auto-provision: создан %s · серверов %d\n", out.Username, out.Imported)
|
|
||||||
} else if out.Imported > 0 {
|
|
||||||
fmt.Fprintf(a.logBuf, "auto-provision: обновлены конфиги %s · серверов %d\n", out.Username, out.Imported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustBind(w webview2.WebView, name string, fn interface{}) {
|
func mustBind(w webview2.WebView, name string, fn interface{}) {
|
||||||
if err := w.Bind(name, fn); err != nil {
|
if err := w.Bind(name, fn); err != nil {
|
||||||
fatalDialog("bind %s: %v", name, err)
|
fatalDialog("bind %s: %v", name, err)
|
||||||
@@ -249,8 +225,6 @@ func (a *app) getState() (uiState, error) {
|
|||||||
Remnawave: a.mgr.RemnawaveSettings(),
|
Remnawave: a.mgr.RemnawaveSettings(),
|
||||||
Hy2: a.mgr.ActiveHy2Options(),
|
Hy2: a.mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
ProvisionReady: a.mgr.ProvisionConfigured(),
|
|
||||||
Account: a.mgr.Account(),
|
|
||||||
Mode: a.mgr.Mode(),
|
Mode: a.mgr.Mode(),
|
||||||
VPNSupported: a.mgr.VPNModeSupported(),
|
VPNSupported: a.mgr.VPNModeSupported(),
|
||||||
VPNActive: st.VPNActive,
|
VPNActive: st.VPNActive,
|
||||||
@@ -377,14 +351,6 @@ func (a *app) importRemnawave(s remnawave.Settings) (core.ImportResult, error) {
|
|||||||
return a.mgr.ImportRemnawave(s)
|
return a.mgr.ImportRemnawave(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) provisionUser(username string) (core.ProvisionOutcome, error) {
|
|
||||||
return a.mgr.ProvisionAccess(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) renewAccount() (core.ProvisionOutcome, error) {
|
|
||||||
return a.mgr.RenewAccount()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) setMode(mode string) error {
|
func (a *app) setMode(mode string) error {
|
||||||
return a.mgr.SetMode(strings.TrimSpace(mode))
|
return a.mgr.SetMode(strings.TrimSpace(mode))
|
||||||
}
|
}
|
||||||
@@ -551,21 +517,12 @@ func (a *app) autoCheckUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openURL(raw string) error {
|
func openURL(raw string) error {
|
||||||
raw = strings.TrimSpace(raw)
|
// Shared allowlist: shop + «Рекомендуемые сервисы» hosts only.
|
||||||
if raw == "" {
|
normalized, err := apphost.LinkAllowed(raw)
|
||||||
return fmt.Errorf("пустая ссылка")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(raw)
|
return shellOpen(normalized)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellOpen(raw string) error {
|
func shellOpen(raw string) error {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"panel_url": "https://panel.example.com",
|
|
||||||
"api_token": "PASTE_API_TOKEN_HERE",
|
|
||||||
"caddy_api_key": "",
|
|
||||||
"provision": {
|
|
||||||
"traffic_gb": 50,
|
|
||||||
"days": 30,
|
|
||||||
"strategy": "MONTH",
|
|
||||||
"hwid_device_limit": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "4.0.0",
|
"version": "4.0.1",
|
||||||
"notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.",
|
"notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "4.0.0",
|
"version": "4.0.1",
|
||||||
"notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.",
|
"notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+32
-41
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -58,8 +59,6 @@ type UIState struct {
|
|||||||
Remnawave remnawave.Settings `json:"remnawave"`
|
Remnawave remnawave.Settings `json:"remnawave"`
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
ProvisionReady bool `json:"provision_ready,omitempty"`
|
|
||||||
Account *remnawave.Account `json:"account,omitempty"`
|
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
VPNSupported bool `json:"vpn_mode_supported"`
|
VPNSupported bool `json:"vpn_mode_supported"`
|
||||||
VPNActive bool `json:"vpn_active"`
|
VPNActive bool `json:"vpn_active"`
|
||||||
@@ -74,20 +73,6 @@ type PingBestResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
||||||
// Monthly plan upkeep: check at start and then periodically.
|
|
||||||
mgr.StartAutoRenew(6 * time.Hour)
|
|
||||||
// NordVPN-style: silently issue a panel account + import configs.
|
|
||||||
go func() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if !mgr.ProvisionConfigured() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if out, err := mgr.EnsureAutoProvision(); err != nil {
|
|
||||||
fmt.Fprintf(logBuf, "auto-provision: %v\n", err)
|
|
||||||
} else if out.Created {
|
|
||||||
fmt.Fprintf(logBuf, "auto-provision: создан %s · серверов %d\n", out.Username, out.Imported)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return &App{
|
return &App{
|
||||||
Mgr: mgr,
|
Mgr: mgr,
|
||||||
CfgPath: cfgPath,
|
CfgPath: cfgPath,
|
||||||
@@ -156,8 +141,6 @@ func (a *App) GetState() (UIState, error) {
|
|||||||
Remnawave: a.Mgr.RemnawaveSettings(),
|
Remnawave: a.Mgr.RemnawaveSettings(),
|
||||||
Hy2: a.Mgr.ActiveHy2Options(),
|
Hy2: a.Mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
ProvisionReady: a.Mgr.ProvisionConfigured(),
|
|
||||||
Account: a.Mgr.Account(),
|
|
||||||
Mode: a.Mgr.Mode(),
|
Mode: a.Mgr.Mode(),
|
||||||
VPNSupported: a.Mgr.VPNModeSupported(),
|
VPNSupported: a.Mgr.VPNModeSupported(),
|
||||||
VPNActive: st.VPNActive,
|
VPNActive: st.VPNActive,
|
||||||
@@ -277,14 +260,6 @@ func (a *App) ImportRemnawave(s remnawave.Settings) (core.ImportResult, error) {
|
|||||||
return a.Mgr.ImportRemnawave(s)
|
return a.Mgr.ImportRemnawave(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ProvisionUser(username string) (core.ProvisionOutcome, error) {
|
|
||||||
return a.Mgr.ProvisionAccess(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) RenewAccount() (core.ProvisionOutcome, error) {
|
|
||||||
return a.Mgr.RenewAccount()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) SetMode(mode string) error {
|
func (a *App) SetMode(mode string) error {
|
||||||
return a.Mgr.SetMode(strings.TrimSpace(mode))
|
return a.Mgr.SetMode(strings.TrimSpace(mode))
|
||||||
}
|
}
|
||||||
@@ -443,22 +418,42 @@ func (a *App) AutoCheckUpdate() {
|
|||||||
_, _ = a.CheckUpdate()
|
_, _ = a.CheckUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) OpenShopURL(raw string) error {
|
// allowedLinkHosts are the only hosts the UI may open in the default browser
|
||||||
|
// (shop button + «Рекомендуемые сервисы» block in index.html).
|
||||||
|
var allowedLinkHosts = map[string]bool{
|
||||||
|
"evilfox.win": true,
|
||||||
|
"t.me": true,
|
||||||
|
"de4ima.uk": true,
|
||||||
|
"git.de4ima.uk": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkAllowed validates that raw is an http(s) URL pointing at one of the
|
||||||
|
// allowed hosts; returns the normalized URL.
|
||||||
|
func LinkAllowed(raw string) (string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return fmt.Errorf("пустая ссылка")
|
return "", fmt.Errorf("пустая ссылка")
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(raw)
|
u, err := url.Parse(raw)
|
||||||
switch {
|
if err != nil {
|
||||||
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
return "", fmt.Errorf("некорректная ссылка")
|
||||||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
}
|
||||||
raw = "https://evilfox.win/"
|
if u.Scheme != "https" && u.Scheme != "http" {
|
||||||
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
return "", fmt.Errorf("разрешены только http(s) ссылки")
|
||||||
default:
|
}
|
||||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
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 {
|
if a.OpenURL != nil {
|
||||||
return a.OpenURL(raw)
|
return a.OpenURL(normalized)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("openURL не настроен")
|
return fmt.Errorf("openURL не настроен")
|
||||||
}
|
}
|
||||||
@@ -560,10 +555,6 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
_ = json.Unmarshal(args[0], &s)
|
_ = json.Unmarshal(args[0], &s)
|
||||||
}
|
}
|
||||||
return a.ImportRemnawave(s)
|
return a.ImportRemnawave(s)
|
||||||
case "provisionUser":
|
|
||||||
return a.ProvisionUser(arg(args, 0, ""))
|
|
||||||
case "renewAccount":
|
|
||||||
return a.RenewAccount()
|
|
||||||
case "setMode":
|
case "setMode":
|
||||||
return nil, a.SetMode(arg(args, 0, ""))
|
return nil, a.SetMode(arg(args, 0, ""))
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
|
|||||||
+83
-81
@@ -473,7 +473,7 @@
|
|||||||
.cur-server .ms.mid { color: var(--ms-mid); }
|
.cur-server .ms.mid { color: var(--ms-mid); }
|
||||||
.cur-server .ms.bad { color: var(--danger); }
|
.cur-server .ms.bad { color: var(--danger); }
|
||||||
|
|
||||||
/* Account / subscription cards */
|
/* Subscription card */
|
||||||
.subinfo {
|
.subinfo {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -936,6 +936,43 @@
|
|||||||
}
|
}
|
||||||
.shop-link:hover { text-decoration: underline; }
|
.shop-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* «Рекомендуемые сервисы» — маленький ненавязчивый блок в углу «О программе» */
|
||||||
|
.svc {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px 11px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.svc h4 {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-size: .66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.svc-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.svc-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: .74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.svc-link:hover { color: var(--accent-deep); border-color: rgba(13,138,102,.45); }
|
||||||
|
.svc-link .ic { font-size: .85rem; line-height: 1; }
|
||||||
|
|
||||||
.about-card {
|
.about-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -1077,25 +1114,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Аккаунт: показывается, когда пользователь «запомнен» (configs/account.json) -->
|
|
||||||
<section class="subinfo" id="accountBox" hidden>
|
|
||||||
<div class="subinfo-title">Аккаунт</div>
|
|
||||||
<div class="subinfo-row">
|
|
||||||
<span class="k">Пользователь</span>
|
|
||||||
<span class="v" id="accName"></span>
|
|
||||||
</div>
|
|
||||||
<div class="subinfo-row" id="accExpireRow" hidden>
|
|
||||||
<span class="k">Действует до</span>
|
|
||||||
<span class="v" id="accExpire"></span>
|
|
||||||
</div>
|
|
||||||
<div class="subinfo-row" id="accTrafficRow" hidden>
|
|
||||||
<span class="k">Тариф</span>
|
|
||||||
<span class="v" id="accTraffic"></span>
|
|
||||||
</div>
|
|
||||||
<button class="action primary" id="renewBtn" type="button">Продлить 50 ГБ</button>
|
|
||||||
<p class="remna-hint" style="margin-top:2px">Продление сбрасывает трафик и добавляет 30 дней тому же пользователю. Также продлевается автоматически при истечении.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="subinfo" id="subInfoCard" hidden>
|
<section class="subinfo" id="subInfoCard" hidden>
|
||||||
<div class="subinfo-title">Подписка</div>
|
<div class="subinfo-title">Подписка</div>
|
||||||
<div class="subinfo-row" id="subExpireRow" hidden>
|
<div class="subinfo-row" id="subExpireRow" hidden>
|
||||||
@@ -1292,6 +1310,12 @@
|
|||||||
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
|
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
|
||||||
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
|
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Рекомендуемые сервисы: ненавязчивый блок в нижнем углу «О программе».
|
||||||
|
Ссылки задаются в скрипте — константа RECOMMENDED_SERVICES. -->
|
||||||
|
<section class="svc" aria-label="Рекомендуемые сервисы">
|
||||||
|
<h4>Рекомендуемые сервисы</h4>
|
||||||
|
<div class="svc-grid" id="svcGrid"></div>
|
||||||
|
</section>
|
||||||
<button class="shop-link" id="quitBtn" type="button" hidden style="display:none;margin:12px auto 0;width:auto">Выйти</button>
|
<button class="shop-link" id="quitBtn" type="button" hidden style="display:none;margin:12px auto 0;width:auto">Выйти</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1327,7 +1351,7 @@
|
|||||||
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
||||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
|
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave",
|
||||||
"provisionUser","renewAccount","setMode","getLogs","quit"
|
"setMode","getLogs","quit"
|
||||||
];
|
];
|
||||||
if (typeof window.getState === "function") return;
|
if (typeof window.getState === "function") return;
|
||||||
window.__navisHttp = true;
|
window.__navisHttp = true;
|
||||||
@@ -1378,8 +1402,6 @@
|
|||||||
const storeUpdateNote = $("storeUpdateNote");
|
const storeUpdateNote = $("storeUpdateNote");
|
||||||
const themeToggle = $("themeToggle");
|
const themeToggle = $("themeToggle");
|
||||||
const themeSwitch = $("themeSwitch");
|
const themeSwitch = $("themeSwitch");
|
||||||
const renewBtn = $("renewBtn");
|
|
||||||
const accountBox = $("accountBox");
|
|
||||||
const modeProxyBtn = $("modeProxyBtn");
|
const modeProxyBtn = $("modeProxyBtn");
|
||||||
const modeVpnBtn = $("modeVpnBtn");
|
const modeVpnBtn = $("modeVpnBtn");
|
||||||
const modeSection = $("modeSection");
|
const modeSection = $("modeSection");
|
||||||
@@ -1464,6 +1486,15 @@
|
|||||||
const protoChip = $("protoChip");
|
const protoChip = $("protoChip");
|
||||||
const heroHint = $("heroHint");
|
const heroHint = $("heroHint");
|
||||||
const SHOP_URL = "https://evilfox.win/";
|
const SHOP_URL = "https://evilfox.win/";
|
||||||
|
// Список рекомендуемых сервисов — замените ссылки/названия здесь.
|
||||||
|
// Хост каждой ссылки должен быть разрешён в allowlist openURL
|
||||||
|
// (internal/apphost/app.go → allowedLinkHosts), иначе ссылка не откроется.
|
||||||
|
const RECOMMENDED_SERVICES = [
|
||||||
|
{ icon: "🛒", name: "EvilFox Shop", url: "https://evilfox.win/" },
|
||||||
|
{ icon: "✈️", name: "Telegram", url: "https://t.me/evilfox" },
|
||||||
|
{ icon: "🖥️", name: "Хостинг de4ima", url: "https://de4ima.uk" },
|
||||||
|
{ icon: "🦊", name: "Git", url: "https://git.de4ima.uk/Evilfox/navi" }
|
||||||
|
];
|
||||||
const AUTO_BEST_KEY = "navis.autoBest";
|
const AUTO_BEST_KEY = "navis.autoBest";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1736,44 +1767,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAccount(acc, subInfo) {
|
|
||||||
if (!accountBox) return;
|
|
||||||
if (!acc) {
|
|
||||||
accountBox.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
accountBox.hidden = false;
|
|
||||||
$("accName").textContent = acc.username || "—";
|
|
||||||
|
|
||||||
const expRow = $("accExpireRow");
|
|
||||||
if (acc.expire_at) {
|
|
||||||
expRow.hidden = false;
|
|
||||||
const daysLeft = Math.floor((acc.expire_at * 1000 - Date.now()) / 86400000);
|
|
||||||
const el = $("accExpire");
|
|
||||||
if (daysLeft < 0) {
|
|
||||||
el.textContent = fmtDate(acc.expire_at) + " · истёк";
|
|
||||||
el.classList.add("warn");
|
|
||||||
} else {
|
|
||||||
el.textContent = fmtDate(acc.expire_at) + " · осталось " + daysLeft + " дн.";
|
|
||||||
el.classList.toggle("warn", daysLeft <= 3);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
expRow.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trafRow = $("accTrafficRow");
|
|
||||||
const limit = acc.traffic_limit_bytes || 0;
|
|
||||||
const used = subInfo ? ((subInfo.upload || 0) + (subInfo.download || 0)) : 0;
|
|
||||||
if (limit > 0 || used > 0) {
|
|
||||||
trafRow.hidden = false;
|
|
||||||
$("accTraffic").textContent = limit > 0
|
|
||||||
? (fmtTraffic(used) + " из " + fmtTraffic(limit))
|
|
||||||
: (fmtTraffic(used) + " · Безлимит");
|
|
||||||
} else {
|
|
||||||
trafRow.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMode(state) {
|
function renderMode(state) {
|
||||||
currentMode = state.mode === "vpn" ? "vpn" : "proxy";
|
currentMode = state.mode === "vpn" ? "vpn" : "proxy";
|
||||||
if (modeSection) {
|
if (modeSection) {
|
||||||
@@ -1894,7 +1887,6 @@
|
|||||||
updateBtn.disabled = busy;
|
updateBtn.disabled = busy;
|
||||||
subBtn.disabled = busy;
|
subBtn.disabled = busy;
|
||||||
subUrl.disabled = busy;
|
subUrl.disabled = busy;
|
||||||
if (renewBtn) renewBtn.disabled = busy || !state.provision_ready;
|
|
||||||
if (modeProxyBtn) modeProxyBtn.disabled = busy || connected;
|
if (modeProxyBtn) modeProxyBtn.disabled = busy || connected;
|
||||||
if (modeVpnBtn) modeVpnBtn.disabled = busy || connected;
|
if (modeVpnBtn) modeVpnBtn.disabled = busy || connected;
|
||||||
btn.disabled = busy;
|
btn.disabled = busy;
|
||||||
@@ -1903,7 +1895,6 @@
|
|||||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||||
renderUpdate(state.update, state.version);
|
renderUpdate(state.update, state.version);
|
||||||
renderSubInfo(state.sub_info);
|
renderSubInfo(state.sub_info);
|
||||||
renderAccount(state.account, state.sub_info);
|
|
||||||
renderMode(state);
|
renderMode(state);
|
||||||
if (typeof state.subscription_url === "string" && !dirty) {
|
if (typeof state.subscription_url === "string" && !dirty) {
|
||||||
subUrl.value = state.subscription_url;
|
subUrl.value = state.subscription_url;
|
||||||
@@ -1975,7 +1966,7 @@
|
|||||||
|
|
||||||
function paintButtonsLocked(locked) {
|
function paintButtonsLocked(locked) {
|
||||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, aboutUpdBtn,
|
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, aboutUpdBtn,
|
||||||
updateBtn, skipUpdateBtn, subBtn, subUrl, renewBtn, modeProxyBtn, modeVpnBtn, logsRefreshBtn].forEach((b) => {
|
updateBtn, skipUpdateBtn, subBtn, subUrl, modeProxyBtn, modeVpnBtn, logsRefreshBtn].forEach((b) => {
|
||||||
if (b) b.disabled = locked;
|
if (b) b.disabled = locked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2079,6 +2070,34 @@
|
|||||||
shopBtn.addEventListener("click", openShop);
|
shopBtn.addEventListener("click", openShop);
|
||||||
shopLink.addEventListener("click", openShop);
|
shopLink.addEventListener("click", openShop);
|
||||||
|
|
||||||
|
// «Рекомендуемые сервисы» — рендер из RECOMMENDED_SERVICES (см. константу выше).
|
||||||
|
(function renderRecommendedServices() {
|
||||||
|
const grid = $("svcGrid");
|
||||||
|
if (!grid) return;
|
||||||
|
RECOMMENDED_SERVICES.forEach((s) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "svc-link";
|
||||||
|
b.title = s.url;
|
||||||
|
const ic = document.createElement("span");
|
||||||
|
ic.className = "ic";
|
||||||
|
ic.textContent = s.icon || "•";
|
||||||
|
const nm = document.createElement("span");
|
||||||
|
nm.textContent = s.name;
|
||||||
|
b.append(ic, nm);
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
setMeta("Открываю " + s.name + "…");
|
||||||
|
await openURL(s.url);
|
||||||
|
setMeta("Открыто в браузере", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
setMeta("Не удалось открыть ссылку: " + String(err), "err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
grid.appendChild(b);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
async function runImportSubscription() {
|
async function runImportSubscription() {
|
||||||
const url = subUrl.value.trim();
|
const url = subUrl.value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -2114,23 +2133,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (renewBtn) {
|
|
||||||
renewBtn.addEventListener("click", () => withBusy(async () => {
|
|
||||||
try {
|
|
||||||
setMeta("Продление тарифа (50 ГБ / +30 дней)…");
|
|
||||||
const r = await renewAccount();
|
|
||||||
formHydrated = false;
|
|
||||||
dirty = false;
|
|
||||||
let msg = "Продлено: " + r.username;
|
|
||||||
if (r.traffic_limit_bytes > 0) msg += " · " + fmtTraffic(r.traffic_limit_bytes);
|
|
||||||
if (r.expire_at) {
|
|
||||||
try { msg += " · до " + new Date(r.expire_at * 1000).toLocaleDateString("ru-RU"); } catch (_) {}
|
|
||||||
}
|
|
||||||
setMeta(msg, "ok");
|
|
||||||
} catch (e) { setMeta(String(e), "err"); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchMode(mode) {
|
async function switchMode(mode) {
|
||||||
if (mode === currentMode) return;
|
if (mode === currentMode) return;
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
|
|||||||
+1
-234
@@ -15,11 +15,11 @@ import (
|
|||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
"vpnclient/internal/linknorm"
|
"vpnclient/internal/linknorm"
|
||||||
|
"vpnclient/internal/netcheck"
|
||||||
"vpnclient/internal/protocols/awg"
|
"vpnclient/internal/protocols/awg"
|
||||||
"vpnclient/internal/protocols/hysteria2"
|
"vpnclient/internal/protocols/hysteria2"
|
||||||
"vpnclient/internal/protocols/naive"
|
"vpnclient/internal/protocols/naive"
|
||||||
"vpnclient/internal/protocols/xray"
|
"vpnclient/internal/protocols/xray"
|
||||||
"vpnclient/internal/netcheck"
|
|
||||||
"vpnclient/internal/remnawave"
|
"vpnclient/internal/remnawave"
|
||||||
"vpnclient/internal/subscription"
|
"vpnclient/internal/subscription"
|
||||||
"vpnclient/internal/sysproxy"
|
"vpnclient/internal/sysproxy"
|
||||||
@@ -37,12 +37,8 @@ type Manager struct {
|
|||||||
stderr io.Writer
|
stderr io.Writer
|
||||||
binDir string
|
binDir string
|
||||||
|
|
||||||
// account is the locally remembered Remnawave user (configs/account.json).
|
|
||||||
account *remnawave.Account
|
|
||||||
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
|
// vpn is the active VPN-mode (TUN) session, nil in proxy mode.
|
||||||
vpn *vpnmode.Session
|
vpn *vpnmode.Session
|
||||||
// renewMu serializes manual + automatic renewals.
|
|
||||||
renewMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||||
@@ -53,18 +49,12 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
acc, err := remnawave.LoadAccount(remnawave.AccountPath(cfgPath))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(stderr, "account.json: %v\n", err)
|
|
||||||
acc = nil
|
|
||||||
}
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
cfgPath: cfgPath,
|
cfgPath: cfgPath,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
sys: sysproxy.New(),
|
sys: sysproxy.New(),
|
||||||
stderr: stderr,
|
stderr: stderr,
|
||||||
binDir: binDir,
|
binDir: binDir,
|
||||||
account: acc,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,229 +679,6 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul
|
|||||||
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
|
return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionOutcome is what the UI shows after issuing access via the panel.
|
|
||||||
type ProvisionOutcome struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Created bool `json:"created"`
|
|
||||||
SubscriptionURL string `json:"subscription_url"`
|
|
||||||
TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"`
|
|
||||||
ExpireAt int64 `json:"expire_at,omitempty"`
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvisionConfigured reports whether the Remnawave admin API is available
|
|
||||||
// (embedded credentials or configs/remnawave-api.json). Used for renew UI.
|
|
||||||
func (m *Manager) ProvisionConfigured() bool {
|
|
||||||
if remnawave.EmbeddedAdminConfig() != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
st, err := os.Stat(remnawave.AdminConfigPath(m.cfgPath))
|
|
||||||
return err == nil && !st.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account returns a copy of the remembered panel account (nil when absent).
|
|
||||||
func (m *Manager) Account() *remnawave.Account {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.account == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cp := *m.account
|
|
||||||
return &cp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) saveAccount(a *remnawave.Account) {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.account = a
|
|
||||||
m.mu.Unlock()
|
|
||||||
if err := remnawave.SaveAccount(remnawave.AccountPath(m.cfgPath), a); err != nil {
|
|
||||||
fmt.Fprintf(m.stderr, "account.json: сохранение не удалось: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API,
|
|
||||||
// remembers it locally and imports its subscription configs into the app.
|
|
||||||
// Once an account is remembered, creating another one is refused —
|
|
||||||
// paid subscription flow will replace free provisioning later.
|
|
||||||
func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) {
|
|
||||||
if acc := m.Account(); acc != nil {
|
|
||||||
return ProvisionOutcome{}, fmt.Errorf("аккаунт уже привязан (%s) — новый создавать нельзя, используйте «Продлить»", acc.Username)
|
|
||||||
}
|
|
||||||
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return ProvisionOutcome{}, err
|
|
||||||
}
|
|
||||||
return m.provisionWithConfig(cfg, username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureAutoProvision silently creates a panel user (if none remembered yet)
|
|
||||||
// and imports its subscription configs. NordVPN-style: panel address / API
|
|
||||||
// token are embedded; the user never edits them. Safe to call on every start.
|
|
||||||
func (m *Manager) EnsureAutoProvision() (ProvisionOutcome, error) {
|
|
||||||
if acc := m.Account(); acc != nil {
|
|
||||||
// Already provisioned — refresh configs in the background if empty.
|
|
||||||
if len(m.Profiles()) == 0 && acc.SubscriptionURL != "" {
|
|
||||||
if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil {
|
|
||||||
return ProvisionOutcome{
|
|
||||||
Username: acc.Username,
|
|
||||||
SubscriptionURL: acc.SubscriptionURL,
|
|
||||||
TrafficLimitBytes: acc.TrafficLimitBytes,
|
|
||||||
ExpireAt: acc.ExpireAt,
|
|
||||||
Imported: imp.Imported,
|
|
||||||
Skipped: imp.Skipped,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ProvisionOutcome{
|
|
||||||
Username: acc.Username,
|
|
||||||
SubscriptionURL: acc.SubscriptionURL,
|
|
||||||
TrafficLimitBytes: acc.TrafficLimitBytes,
|
|
||||||
ExpireAt: acc.ExpireAt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return ProvisionOutcome{}, err
|
|
||||||
}
|
|
||||||
return m.provisionWithConfig(cfg, remnawave.GenerateUsername())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) provisionWithConfig(cfg *remnawave.AdminConfig, username string) (ProvisionOutcome, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
res, err := remnawave.ProvisionUser(ctx, cfg, username)
|
|
||||||
if err != nil {
|
|
||||||
return ProvisionOutcome{}, err
|
|
||||||
}
|
|
||||||
out := ProvisionOutcome{
|
|
||||||
Username: res.Username,
|
|
||||||
Created: res.Created,
|
|
||||||
SubscriptionURL: res.SubscriptionURL,
|
|
||||||
TrafficLimitBytes: res.TrafficLimitBytes,
|
|
||||||
ExpireAt: res.ExpireAt,
|
|
||||||
}
|
|
||||||
subURL := res.SubscriptionURL
|
|
||||||
if subURL == "" && res.ShortUUID != "" {
|
|
||||||
subURL = remnawave.PublicSubURL(cfg.PanelURL, res.ShortUUID)
|
|
||||||
out.SubscriptionURL = subURL
|
|
||||||
}
|
|
||||||
if subURL == "" {
|
|
||||||
return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember the account — from now on only renewals of this user.
|
|
||||||
m.saveAccount(&remnawave.Account{
|
|
||||||
Username: res.Username,
|
|
||||||
UUID: res.UUID,
|
|
||||||
ShortUUID: res.ShortUUID,
|
|
||||||
SubscriptionURL: subURL,
|
|
||||||
TrafficLimitBytes: res.TrafficLimitBytes,
|
|
||||||
ExpireAt: res.ExpireAt,
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
imp, err := m.ImportSubscription(subURL)
|
|
||||||
if err != nil {
|
|
||||||
return out, fmt.Errorf("доступ выдан (%s), но импорт конфигов не удался: %w", res.Username, err)
|
|
||||||
}
|
|
||||||
out.Imported = imp.Imported
|
|
||||||
out.Skipped = imp.Skipped
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenewAccount extends the remembered user: resets traffic to the plan
|
|
||||||
// (50 GB default) and pushes expiry +30 days. Never creates users.
|
|
||||||
func (m *Manager) RenewAccount() (ProvisionOutcome, error) {
|
|
||||||
m.renewMu.Lock()
|
|
||||||
defer m.renewMu.Unlock()
|
|
||||||
|
|
||||||
acc := m.Account()
|
|
||||||
if acc == nil {
|
|
||||||
return ProvisionOutcome{}, fmt.Errorf("нет привязанного аккаунта — сначала выдайте доступ")
|
|
||||||
}
|
|
||||||
cfg, err := remnawave.ResolveAdminConfig(m.cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return ProvisionOutcome{}, err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
res, err := remnawave.RenewUser(ctx, cfg, acc.UUID, acc.ExpireAt)
|
|
||||||
if err != nil {
|
|
||||||
return ProvisionOutcome{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.LastRenewedAt = time.Now().Unix()
|
|
||||||
if res.ExpireAt > 0 {
|
|
||||||
acc.ExpireAt = res.ExpireAt
|
|
||||||
}
|
|
||||||
if res.TrafficLimitBytes > 0 {
|
|
||||||
acc.TrafficLimitBytes = res.TrafficLimitBytes
|
|
||||||
}
|
|
||||||
if res.SubscriptionURL != "" {
|
|
||||||
acc.SubscriptionURL = res.SubscriptionURL
|
|
||||||
}
|
|
||||||
m.saveAccount(acc)
|
|
||||||
|
|
||||||
out := ProvisionOutcome{
|
|
||||||
Username: acc.Username,
|
|
||||||
SubscriptionURL: acc.SubscriptionURL,
|
|
||||||
TrafficLimitBytes: acc.TrafficLimitBytes,
|
|
||||||
ExpireAt: acc.ExpireAt,
|
|
||||||
}
|
|
||||||
// Refresh configs + usage info; renewal itself already succeeded.
|
|
||||||
if acc.SubscriptionURL != "" {
|
|
||||||
if imp, err := m.ImportSubscription(acc.SubscriptionURL); err == nil {
|
|
||||||
out.Imported = imp.Imported
|
|
||||||
out.Skipped = imp.Skipped
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(m.stderr, "renew: обновление подписки после продления: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintf(m.stderr, "renew: аккаунт %s продлён до %s\n", acc.Username,
|
|
||||||
time.Unix(acc.ExpireAt, 0).Format("2006-01-02"))
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartAutoRenew launches the monthly auto-renew loop: checks on start and
|
|
||||||
// then every interval (6h default); renews when expiry is near/past or the
|
|
||||||
// traffic is exhausted. LastRenewedAt guards against renew loops.
|
|
||||||
func (m *Manager) StartAutoRenew(interval time.Duration) {
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = 6 * time.Hour
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
time.Sleep(15 * time.Second) // let the app start up first
|
|
||||||
for {
|
|
||||||
m.maybeAutoRenew()
|
|
||||||
time.Sleep(interval)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) maybeAutoRenew() {
|
|
||||||
acc := m.Account()
|
|
||||||
if acc == nil || !m.ProvisionConfigured() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
// Anti-loop: never auto-renew more often than every 12 hours.
|
|
||||||
if acc.LastRenewedAt > 0 && now.Sub(time.Unix(acc.LastRenewedAt, 0)) < 12*time.Hour {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var used int64
|
|
||||||
if si := m.SubscriptionInfo(); si != nil {
|
|
||||||
used = si.Upload + si.Download
|
|
||||||
}
|
|
||||||
if !acc.NeedsRenewal(used, now) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(m.stderr, "auto-renew: аккаунт %s требует продления (истекает/трафик исчерпан) — продлеваю…\n", acc.Username)
|
|
||||||
if _, err := m.RenewAccount(); err != nil {
|
|
||||||
fmt.Fprintf(m.stderr, "auto-renew: не удалось: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
|
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -88,17 +87,3 @@ func TestSaveProfileKeepsNaiveLink(t *testing.T) {
|
|||||||
t.Fatalf("naive profile broken: %+v", p)
|
t.Fatalf("naive profile broken: %+v", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvisionConfigured(t *testing.T) {
|
|
||||||
m := newTestManager(t)
|
|
||||||
if m.ProvisionConfigured() {
|
|
||||||
t.Fatal("should be false without remnawave-api.json")
|
|
||||||
}
|
|
||||||
path := filepath.Join(filepath.Dir(m.ConfigPath()), "remnawave-api.json")
|
|
||||||
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !m.ProvisionConfigured() {
|
|
||||||
t.Fatal("should be true with remnawave-api.json present")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package remnawave
|
|
||||||
|
|
||||||
// Local account storage: after the first successful provisioning the client
|
|
||||||
// "remembers" the panel user in configs/account.json (never committed) and
|
|
||||||
// from then on only renews that same user — it never creates a second one.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Account is the locally remembered panel user.
|
|
||||||
type Account struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
UUID string `json:"uuid"`
|
|
||||||
ShortUUID string `json:"short_uuid,omitempty"`
|
|
||||||
SubscriptionURL string `json:"subscription_url,omitempty"`
|
|
||||||
TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"`
|
|
||||||
ExpireAt int64 `json:"expire_at,omitempty"` // unix seconds; 0 = unknown
|
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // unix seconds
|
|
||||||
LastRenewedAt int64 `json:"last_renewed_at,omitempty"` // unix seconds; anti-loop guard
|
|
||||||
LastRenewCheckAt int64 `json:"last_renew_check,omitempty"` // unix seconds of last auto check
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountPath returns configs/account.json next to the main config.json.
|
|
||||||
func AccountPath(cfgPath string) string {
|
|
||||||
return filepath.Join(filepath.Dir(cfgPath), "account.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAccount reads the remembered account; returns (nil, nil) when absent.
|
|
||||||
func LoadAccount(path string) (*Account, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var a Account
|
|
||||||
if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("account.json: некорректный JSON: %w", err)
|
|
||||||
}
|
|
||||||
if a.Username == "" && a.UUID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveAccount persists the account with restrictive permissions.
|
|
||||||
func SaveAccount(path string, a *Account) error {
|
|
||||||
if a == nil {
|
|
||||||
return fmt.Errorf("пустой аккаунт")
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(a, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data = append(data, '\n')
|
|
||||||
if dir := filepath.Dir(path); dir != "." && dir != "" {
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeedsRenewal reports whether the plan should be renewed now:
|
|
||||||
// expiry within 72h / past, or traffic exhausted (usedBytes vs limit).
|
|
||||||
func (a *Account) NeedsRenewal(usedBytes int64, now time.Time) bool {
|
|
||||||
if a == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if a.ExpireAt > 0 && time.Unix(a.ExpireAt, 0).Before(now.Add(72*time.Hour)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if a.TrafficLimitBytes > 0 && usedBytes >= a.TrafficLimitBytes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package remnawave
|
|
||||||
|
|
||||||
// Centralized (NordVPN-style) provisioning: the panel address and API token
|
|
||||||
// are baked into the binary at build time, so every user gets configs
|
|
||||||
// automatically and cannot change the panel.
|
|
||||||
//
|
|
||||||
// build.bat copies configs/remnawave-api.json into embeddedcfg/ before
|
|
||||||
// `go build`; the copy is gitignored so credentials never land in the repo.
|
|
||||||
// Without the embedded file the app falls back to the external
|
|
||||||
// configs/remnawave-api.json (developer mode).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"embed"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed embeddedcfg
|
|
||||||
var embeddedCfgFS embed.FS
|
|
||||||
|
|
||||||
// EmbeddedAdminConfig returns the admin config baked into the binary,
|
|
||||||
// or nil when this build has no embedded credentials.
|
|
||||||
func EmbeddedAdminConfig() *AdminConfig {
|
|
||||||
data, err := embeddedCfgFS.ReadFile("embeddedcfg/remnawave-api.json")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg, err := parseAdminConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateUsername makes a unique panel username for silent auto-provisioning
|
|
||||||
// (users never type anything): "fox_" + 10 hex chars, e.g. fox_3fa9c02b71.
|
|
||||||
func GenerateUsername() string {
|
|
||||||
var b [5]byte
|
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
|
||||||
// Keep within Remnawave's 6–34 char username limit.
|
|
||||||
s := fmt.Sprintf("fox_%x%x", os.Getpid()&0xffff, time.Now().UnixNano()&0xffffffff)
|
|
||||||
if len(s) > 34 {
|
|
||||||
s = s[:34]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return "fox_" + hex.EncodeToString(b[:])
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"_comment": "build.bat overwrites remnawave-api.json here from configs/ before go build. This placeholder keeps the embed directory non-empty for go build without credentials."
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
package remnawave
|
|
||||||
|
|
||||||
// Provisioning: the app itself can issue access via the Remnawave admin API
|
|
||||||
// (docs: https://docs.rw / TypeScript SDK commands):
|
|
||||||
//
|
|
||||||
// GET {base}/api/users/by-username/{username} (GetUserByUsernameCommand)
|
|
||||||
// POST {base}/api/users (CreateUserCommand)
|
|
||||||
//
|
|
||||||
// Auth: Authorization: Bearer <admin API token> (+ optional Caddy X-Api-Key).
|
|
||||||
// Credentials live in a separate config file configs/remnawave-api.json —
|
|
||||||
// never committed; see configs/remnawave-api.example.json.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProvisionOptions are plan defaults for newly created users.
|
|
||||||
type ProvisionOptions struct {
|
|
||||||
TrafficGB int64 `json:"traffic_gb"` // default 50
|
|
||||||
Days int `json:"days"` // default 30
|
|
||||||
Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH)
|
|
||||||
HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminConfig is loaded from configs/remnawave-api.json (admin credentials).
|
|
||||||
type AdminConfig struct {
|
|
||||||
PanelURL string `json:"panel_url"`
|
|
||||||
APIToken string `json:"api_token"`
|
|
||||||
CaddyAPIKey string `json:"caddy_api_key,omitempty"`
|
|
||||||
Provision ProvisionOptions `json:"provision"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminConfigPath returns the expected path of remnawave-api.json —
|
|
||||||
// next to the main config.json (configs dir).
|
|
||||||
func AdminConfigPath(cfgPath string) string {
|
|
||||||
return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveAdminConfig prefers the baked-in (embedded) panel credentials;
|
|
||||||
// falls back to configs/remnawave-api.json for local/developer builds.
|
|
||||||
func ResolveAdminConfig(cfgPath string) (*AdminConfig, error) {
|
|
||||||
if cfg := EmbeddedAdminConfig(); cfg != nil {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
return LoadAdminConfig(AdminConfigPath(cfgPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAdminConfig reads and validates the admin API config file.
|
|
||||||
func LoadAdminConfig(path string) (*AdminConfig, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path))
|
|
||||||
}
|
|
||||||
return parseAdminConfig(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAdminConfig validates raw remnawave-api.json bytes (file or embedded).
|
|
||||||
func parseAdminConfig(data []byte) (*AdminConfig, error) {
|
|
||||||
var cfg AdminConfig
|
|
||||||
if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err)
|
|
||||||
}
|
|
||||||
cfg.PanelURL = NormalizeBase(cfg.PanelURL)
|
|
||||||
cfg.APIToken = strings.TrimSpace(cfg.APIToken)
|
|
||||||
cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey)
|
|
||||||
if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") {
|
|
||||||
return nil, fmt.Errorf("remnawave-api.json: укажите panel_url")
|
|
||||||
}
|
|
||||||
if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" {
|
|
||||||
return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)")
|
|
||||||
}
|
|
||||||
// Plan defaults: 50 GB / 30 days / MONTH reset.
|
|
||||||
if cfg.Provision.TrafficGB <= 0 {
|
|
||||||
cfg.Provision.TrafficGB = 50
|
|
||||||
}
|
|
||||||
if cfg.Provision.Days <= 0 {
|
|
||||||
cfg.Provision.Days = 30
|
|
||||||
}
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) {
|
|
||||||
case "NO_RESET", "DAY", "WEEK", "MONTH":
|
|
||||||
cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy))
|
|
||||||
default:
|
|
||||||
cfg.Provision.Strategy = "MONTH"
|
|
||||||
}
|
|
||||||
if cfg.Provision.HwidDeviceLimit < 0 {
|
|
||||||
cfg.Provision.HwidDeviceLimit = 0
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvisionResult describes the created or found panel user.
|
|
||||||
type ProvisionResult struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
UUID string `json:"uuid"`
|
|
||||||
ShortUUID string `json:"short_uuid"`
|
|
||||||
SubscriptionURL string `json:"subscription_url"`
|
|
||||||
TrafficLimitBytes int64 `json:"traffic_limit_bytes"`
|
|
||||||
ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown
|
|
||||||
Created bool `json:"created"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`)
|
|
||||||
|
|
||||||
// NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.)
|
|
||||||
// into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars.
|
|
||||||
func NormalizeUsername(raw string) (string, error) {
|
|
||||||
s := strings.TrimSpace(raw)
|
|
||||||
s = strings.TrimPrefix(s, "@")
|
|
||||||
if i := strings.IndexByte(s, '@'); i > 0 {
|
|
||||||
s = s[:i] // email → local part
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r == '.', r == ' ', r == '+':
|
|
||||||
b.WriteByte('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = b.String()
|
|
||||||
for len(s) > 0 && len(s) < 6 {
|
|
||||||
s += "_vpn" // pad short handles ("ivan" → "ivan_vpn")
|
|
||||||
}
|
|
||||||
if len(s) > 34 {
|
|
||||||
s = s[:34]
|
|
||||||
}
|
|
||||||
if !reUsername.MatchString(s) {
|
|
||||||
return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s)
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvisionUser finds an existing panel user by username or creates one with
|
|
||||||
// the configured plan (traffic_gb / days / strategy / hwid_device_limit),
|
|
||||||
// then returns its subscription URL for import.
|
|
||||||
func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil, fmt.Errorf("нет конфигурации remnawave-api")
|
|
||||||
}
|
|
||||||
username, err := NormalizeUsername(rawUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
// 1) Existing user?
|
|
||||||
getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username)
|
|
||||||
status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case status == http.StatusOK:
|
|
||||||
res, err := parseProvisionUser(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res.Username = username
|
|
||||||
return res, nil
|
|
||||||
case status == http.StatusNotFound:
|
|
||||||
// fall through to create
|
|
||||||
default:
|
|
||||||
return nil, adminHTTPError(status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Create with plan defaults.
|
|
||||||
payload := map[string]any{
|
|
||||||
"username": username,
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024,
|
|
||||||
"trafficLimitStrategy": cfg.Provision.Strategy,
|
|
||||||
"expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if cfg.Provision.HwidDeviceLimit > 0 {
|
|
||||||
payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(payload)
|
|
||||||
status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if status != http.StatusOK && status != http.StatusCreated {
|
|
||||||
return nil, adminHTTPError(status, body)
|
|
||||||
}
|
|
||||||
res, err := parseProvisionUser(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res.Username = username
|
|
||||||
res.Created = true
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenewUser extends the SAME panel user: resets used traffic
|
|
||||||
// (POST /api/users/{uuid}/actions/reset-traffic) and pushes the plan forward
|
|
||||||
// via PATCH /api/users — trafficLimitBytes = plan (50 GB default) and
|
|
||||||
// expireAt = max(now, current expiry) + plan days (30 default).
|
|
||||||
// It never creates users.
|
|
||||||
func RenewUser(ctx context.Context, cfg *AdminConfig, userUUID string, currentExpire int64) (*ProvisionResult, error) {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil, fmt.Errorf("нет конфигурации remnawave-api")
|
|
||||||
}
|
|
||||||
userUUID = strings.TrimSpace(userUUID)
|
|
||||||
if userUUID == "" {
|
|
||||||
return nil, fmt.Errorf("нет UUID пользователя для продления")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
// 1) Reset used traffic. Some panels return 404 for this action route —
|
|
||||||
// tolerate it, the PATCH below still extends the plan.
|
|
||||||
resetURL := cfg.PanelURL + "/api/users/" + url.PathEscape(userUUID) + "/actions/reset-traffic"
|
|
||||||
status, body, err := adminDo(ctx, client, cfg, http.MethodPost, resetURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("сброс трафика: %w", adminHTTPError(status, body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Extend expiry + (re)set the traffic limit.
|
|
||||||
base := time.Now().UTC()
|
|
||||||
if currentExpire > 0 {
|
|
||||||
if cur := time.Unix(currentExpire, 0).UTC(); cur.After(base) {
|
|
||||||
base = cur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"uuid": userUUID,
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024,
|
|
||||||
"expireAt": base.AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(payload)
|
|
||||||
status, body, err = adminDo(ctx, client, cfg, http.MethodPatch, cfg.PanelURL+"/api/users", raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if status != http.StatusOK && status != http.StatusCreated {
|
|
||||||
return nil, fmt.Errorf("продление: %w", adminHTTPError(status, body))
|
|
||||||
}
|
|
||||||
res, err := parseProvisionUser(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) {
|
|
||||||
var rd io.Reader
|
|
||||||
if body != nil {
|
|
||||||
rd = bytes.NewReader(body)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, rd)
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "EvilFox/4.0 (Remnawave provision)")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.APIToken)
|
|
||||||
if cfg.CaddyAPIKey != "" {
|
|
||||||
req.Header.Set("X-Api-Key", cfg.CaddyAPIKey)
|
|
||||||
}
|
|
||||||
if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) {
|
|
||||||
req.Header.Set("X-Forwarded-Proto", u.Scheme)
|
|
||||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", fmt.Errorf("Remnawave API недоступен: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
||||||
if err != nil {
|
|
||||||
return resp.StatusCode, "", err
|
|
||||||
}
|
|
||||||
return resp.StatusCode, string(raw), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func adminHTTPError(status int, body string) error {
|
|
||||||
msg := strings.TrimSpace(body)
|
|
||||||
if len(msg) > 160 {
|
|
||||||
msg = msg[:160] + "…"
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
|
||||||
return fmt.Errorf("Неверный API ключ (HTTP %d) — проверьте api_token в remnawave-api.json", status)
|
|
||||||
case http.StatusConflict:
|
|
||||||
return fmt.Errorf("Пользователь уже существует, но панель вернула конфликт (HTTP 409): %s", msg)
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
return fmt.Errorf("Панель отклонила запрос (HTTP 400): %s", msg)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("Remnawave HTTP %d: %s", status, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseProvisionUser(body string) (*ProvisionResult, error) {
|
|
||||||
var payload struct {
|
|
||||||
Response provisionUserDTO `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(body), &payload); err != nil || payload.Response.UUID == "" {
|
|
||||||
// Some deployments return the DTO without the envelope.
|
|
||||||
var direct provisionUserDTO
|
|
||||||
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil || direct.UUID == "" {
|
|
||||||
return nil, fmt.Errorf("Remnawave: не удалось разобрать ответ панели")
|
|
||||||
}
|
|
||||||
payload.Response = direct
|
|
||||||
}
|
|
||||||
u := payload.Response
|
|
||||||
res := &ProvisionResult{
|
|
||||||
UUID: u.UUID,
|
|
||||||
ShortUUID: u.ShortUUID,
|
|
||||||
SubscriptionURL: strings.TrimSpace(u.SubscriptionURL),
|
|
||||||
TrafficLimitBytes: u.TrafficLimitBytes,
|
|
||||||
}
|
|
||||||
if u.ExpireAt != "" {
|
|
||||||
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
|
|
||||||
res.ExpireAt = t.Unix()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type provisionUserDTO struct {
|
|
||||||
UUID string `json:"uuid"`
|
|
||||||
ShortUUID string `json:"shortUuid"`
|
|
||||||
SubscriptionURL string `json:"subscriptionUrl"`
|
|
||||||
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
|
|
||||||
ExpireAt string `json:"expireAt"`
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package remnawave
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testAdminConfig(baseURL string) *AdminConfig {
|
|
||||||
return &AdminConfig{
|
|
||||||
PanelURL: baseURL,
|
|
||||||
APIToken: "test-token",
|
|
||||||
Provision: ProvisionOptions{
|
|
||||||
TrafficGB: 50,
|
|
||||||
Days: 30,
|
|
||||||
Strategy: "MONTH",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProvisionUserExisting(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/api/users/by-username/ivan_petrov" {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
|
|
||||||
"uuid": "u-1",
|
|
||||||
"shortUuid": "short1",
|
|
||||||
"subscriptionUrl": "https://sub.example.com/short1",
|
|
||||||
"trafficLimitBytes": int64(50) << 30,
|
|
||||||
"expireAt": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "ivan_petrov")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Created {
|
|
||||||
t.Fatal("expected existing user, got created")
|
|
||||||
}
|
|
||||||
if res.SubscriptionURL != "https://sub.example.com/short1" {
|
|
||||||
t.Fatalf("subscription url: %q", res.SubscriptionURL)
|
|
||||||
}
|
|
||||||
if res.Username != "ivan_petrov" {
|
|
||||||
t.Fatalf("username: %q", res.Username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProvisionUserCreate(t *testing.T) {
|
|
||||||
var created map[string]any
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/users/by-username/"):
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/users":
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&created); err != nil {
|
|
||||||
t.Errorf("decode create body: %v", err)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
|
|
||||||
"uuid": "u-2",
|
|
||||||
"shortUuid": "short2",
|
|
||||||
"subscriptionUrl": "https://sub.example.com/short2",
|
|
||||||
"trafficLimitBytes": created["trafficLimitBytes"],
|
|
||||||
"expireAt": created["expireAt"],
|
|
||||||
}})
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "@new_client")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !res.Created {
|
|
||||||
t.Fatal("expected created user")
|
|
||||||
}
|
|
||||||
if res.Username != "new_client" {
|
|
||||||
t.Fatalf("username: %q", res.Username)
|
|
||||||
}
|
|
||||||
if got := created["trafficLimitBytes"].(float64); int64(got) != 50<<30 {
|
|
||||||
t.Fatalf("trafficLimitBytes = %v, want %d (50 GB)", got, int64(50)<<30)
|
|
||||||
}
|
|
||||||
if created["trafficLimitStrategy"] != "MONTH" {
|
|
||||||
t.Fatalf("strategy = %v", created["trafficLimitStrategy"])
|
|
||||||
}
|
|
||||||
if created["status"] != "ACTIVE" {
|
|
||||||
t.Fatalf("status = %v", created["status"])
|
|
||||||
}
|
|
||||||
expireStr, _ := created["expireAt"].(string)
|
|
||||||
expire, err := time.Parse(time.RFC3339, expireStr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expireAt %q: %v", expireStr, err)
|
|
||||||
}
|
|
||||||
days := time.Until(expire).Hours() / 24
|
|
||||||
if days < 29 || days > 31 {
|
|
||||||
t.Fatalf("expireAt %.1f days from now, want ~30", days)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProvisionUserBadToken(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
_, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "somebody1")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "Неверный API ключ") {
|
|
||||||
t.Fatalf("error = %v, want «Неверный API ключ»", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeUsername(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
in, want string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"ivan_petrov", "ivan_petrov", false},
|
|
||||||
{"@tg_handle", "tg_handle", false},
|
|
||||||
{"user@example.com", "user_vpn", false},
|
|
||||||
{"Иван", "", true},
|
|
||||||
{"ab", "ab_vpn", false}, // padded to min length
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got, err := NormalizeUsername(c.in)
|
|
||||||
if c.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%q: expected error, got %q", c.in, got)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%q: %v", c.in, err)
|
|
||||||
}
|
|
||||||
if got != c.want {
|
|
||||||
t.Fatalf("%q: got %q want %q", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAdminConfig(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "remnawave-api.json")
|
|
||||||
|
|
||||||
if _, err := LoadAdminConfig(path); err == nil {
|
|
||||||
t.Fatal("expected error for missing file")
|
|
||||||
}
|
|
||||||
|
|
||||||
body := `{"panel_url":"https://panel.test/","api_token":"tok123","provision":{}}`
|
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cfg, err := LoadAdminConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if cfg.PanelURL != "https://panel.test" {
|
|
||||||
t.Fatalf("panel url: %q", cfg.PanelURL)
|
|
||||||
}
|
|
||||||
if cfg.Provision.TrafficGB != 50 || cfg.Provision.Days != 30 || cfg.Provision.Strategy != "MONTH" {
|
|
||||||
t.Fatalf("defaults not applied: %+v", cfg.Provision)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placeholder token must be rejected.
|
|
||||||
body = `{"panel_url":"https://panel.test","api_token":"PASTE_API_TOKEN_HERE"}`
|
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := LoadAdminConfig(path); err == nil {
|
|
||||||
t.Fatal("expected error for placeholder token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
// CurrentVersion is the product/semver used for update eligibility (feed "version").
|
||||||
// Keep major.minor.patch only — no build suffix here.
|
// Keep major.minor.patch only — no build suffix here.
|
||||||
const CurrentVersion = "4.0.0"
|
const CurrentVersion = "4.0.1"
|
||||||
|
|
||||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="EvilFox.EvilFox"
|
Name="EvilFox.EvilFox"
|
||||||
Publisher="CN=EvilFox"
|
Publisher="CN=EvilFox"
|
||||||
Version="4.0.0.1"
|
Version="4.0.1.1"
|
||||||
ProcessorArchitecture="x64" />
|
ProcessorArchitecture="x64" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\scripts\pack-msix.ps1
|
.\scripts\pack-msix.ps1
|
||||||
.\scripts\pack-msix.ps1 -Version 4.0.0.1 -Publisher "CN=EvilFox"
|
.\scripts\pack-msix.ps1 -Version 4.0.1.1 -Publisher "CN=EvilFox"
|
||||||
#>
|
#>
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$ExePath = "",
|
[string]$ExePath = "",
|
||||||
[string]$OutDir = "",
|
[string]$OutDir = "",
|
||||||
[string]$Version = "4.0.0.1",
|
[string]$Version = "4.0.1.1",
|
||||||
[string]$Name = "EvilFox.EvilFox",
|
[string]$Name = "EvilFox.EvilFox",
|
||||||
[string]$Publisher = "CN=EvilFox",
|
[string]$Publisher = "CN=EvilFox",
|
||||||
[string]$DisplayName = "EvilFox",
|
[string]$DisplayName = "EvilFox",
|
||||||
@@ -82,7 +82,7 @@ $manifestText = Get-Content -LiteralPath $ManifestSrc -Raw -Encoding UTF8
|
|||||||
# Prefer exact placeholder swaps so we never touch <?xml version=...?>.
|
# Prefer exact placeholder swaps so we never touch <?xml version=...?>.
|
||||||
$manifestText = $manifestText.Replace('Name="EvilFox.EvilFox"', "Name=`"$Name`"")
|
$manifestText = $manifestText.Replace('Name="EvilFox.EvilFox"', "Name=`"$Name`"")
|
||||||
$manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"")
|
$manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"")
|
||||||
$manifestText = $manifestText.Replace('Version="4.0.0.1"', "Version=`"$Version`"")
|
$manifestText = $manifestText.Replace('Version="4.0.1.1"', "Version=`"$Version`"")
|
||||||
$manifestText = $manifestText.Replace('<DisplayName>EvilFox</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
|
$manifestText = $manifestText.Replace('<DisplayName>EvilFox</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
|
||||||
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
||||||
$manifestText = $manifestText.Replace('DisplayName="EvilFox"', "DisplayName=`"$DisplayName`"")
|
$manifestText = $manifestText.Replace('DisplayName="EvilFox"', "DisplayName=`"$DisplayName`"")
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "4.0.0",
|
"version": "4.0.1",
|
||||||
"notes": "4.0.0+1: ребрендинг Navis → EvilFox; централизованная автовыдача конфигов (как NordVPN — панель вшита, пользователь только выбирает сервер и продлевает); из интерфейса убраны настройки Remnawave API. Windows 4.0.0.1. Клиенты 3.x обновляются по этому фиду; Navis.exe в релизе — совместимый алиас EvilFox.exe.",
|
"notes": "4.0.1: убрана привязка аккаунта, конфиги — через вашу подписку; добавлен блок рекомендуемых сервисов",
|
||||||
"platform": "windows-amd64",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
"url": "https://git.de4ima.uk/Evilfox/navi/raw/branch/Windows/dist/navis-release/windows/EvilFox.exe",
|
||||||
"sha256": "869b37310526003143c5db8ea3ad6d4468173e9053cf585f06c162a861d5c49c",
|
"sha256": "c9551072d2b6206cd9323ae4eaf2d4dd20377237e837becc785b69539ec79939",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "Major": 4, "Minor": 0, "Patch": 0, "Build": 1 },
|
"FileVersion": { "Major": 4, "Minor": 0, "Patch": 1, "Build": 1 },
|
||||||
"ProductVersion": { "Major": 4, "Minor": 0, "Patch": 0, "Build": 1 },
|
"ProductVersion": { "Major": 4, "Minor": 0, "Patch": 1, "Build": 1 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "EvilFox — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "4.0.0.1",
|
"FileVersion": "4.0.1.1",
|
||||||
"InternalName": "EvilFox",
|
"InternalName": "EvilFox",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "EvilFox.exe",
|
"OriginalFilename": "EvilFox.exe",
|
||||||
"ProductName": "EvilFox",
|
"ProductName": "EvilFox",
|
||||||
"ProductVersion": "4.0.0.1",
|
"ProductVersion": "4.0.1.1",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user