diff --git a/.gitignore b/.gitignore index 6989102..f1db91b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ bin/ !dist/**/*.exe !dist/**/ configs/config.json +configs/remnawave-api.json +dist/navis-release/windows/configs/ configs/.navis-webview/ cmd/vpnapp/resource.syso *.syso diff --git a/README.md b/README.md index 6094e03..4a76555 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,29 @@ build.bat Токены хранятся только локально в `configs/config.json` (не коммитьте). +### Выдача доступа из приложения (admin API) + +Приложение умеет само создавать пользователей в панели Remnawave и сразу импортировать их конфиги (панель «Выдать доступ» в интерфейсе). Для этого нужен **отдельный** файл с админ-ключом: + +1. Скопируйте `configs/remnawave-api.example.json` → `configs/remnawave-api.json`. +2. Вставьте `panel_url` и `api_token` (панель → API Tokens). `caddy_api_key` — только если панель за Caddy-auth. +3. Перезапустите Navis — появится панель «Выдать доступ». + +Файл читается **в рантайме** из папки `configs` рядом с `config.json` — пересборка (repack) не требуется, достаточно положить файл и перезапустить приложение. + +Тариф по умолчанию (настраивается в блоке `provision`): + +| Поле | Значение по умолчанию | +|------|----------------------| +| `traffic_gb` | 50 ГБ | +| `days` | 30 дней (expireAt = сейчас + days) | +| `strategy` | `MONTH` (сброс трафика; также `NO_RESET` / `DAY` / `WEEK`) | +| `hwid_device_limit` | 0 = без лимита устройств | + +Логика: `GET /api/users/by-username/{username}` — если пользователь есть, берётся его `subscriptionUrl`; иначе `POST /api/users` с тарифом выше, затем подписка импортируется в приложение автоматически. + +`configs/remnawave-api.json` в `.gitignore` — **никогда не коммитьте** реальный ключ, в репозитории только `remnawave-api.example.json`. + ## Сборка macOS (кросс с Windows / Linux) ```bat diff --git a/assets/app.manifest b/assets/app.manifest index 46dfb33..ce9a4ee 100644 --- a/assets/app.manifest +++ b/assets/app.manifest @@ -1,7 +1,7 @@ diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index e943539..887dfbe 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -40,26 +40,27 @@ type app struct { } type uiState struct { - Connected bool `json:"connected"` - Profile string `json:"profile,omitempty"` - ActiveProfile string `json:"active_profile,omitempty"` - Protocol string `json:"protocol,omitempty"` - HTTPProxy string `json:"http_proxy,omitempty"` - SOCKSProxy string `json:"socks_proxy,omitempty"` - SystemProxy bool `json:"system_proxy"` - Proxy string `json:"proxy"` - CoreReady bool `json:"core_ready"` - CorePath string `json:"core_path,omitempty"` - ConfigPath string `json:"config_path"` - Profiles []config.ProfileInfo `json:"profiles"` - Version string `json:"version"` - Update update.Status `json:"update"` - Pings []netcheck.Result `json:"pings"` - Subscription string `json:"subscription_url"` - SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"` - Remnawave remnawave.Settings `json:"remnawave"` - Hy2 core.Hy2Options `json:"hy2"` - StorePackaged bool `json:"store_packaged,omitempty"` + Connected bool `json:"connected"` + Profile string `json:"profile,omitempty"` + ActiveProfile string `json:"active_profile,omitempty"` + Protocol string `json:"protocol,omitempty"` + HTTPProxy string `json:"http_proxy,omitempty"` + SOCKSProxy string `json:"socks_proxy,omitempty"` + SystemProxy bool `json:"system_proxy"` + Proxy string `json:"proxy"` + CoreReady bool `json:"core_ready"` + CorePath string `json:"core_path,omitempty"` + ConfigPath string `json:"config_path"` + Profiles []config.ProfileInfo `json:"profiles"` + Version string `json:"version"` + Update update.Status `json:"update"` + Pings []netcheck.Result `json:"pings"` + Subscription string `json:"subscription_url"` + SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"` + Remnawave remnawave.Settings `json:"remnawave"` + Hy2 core.Hy2Options `json:"hy2"` + StorePackaged bool `json:"store_packaged,omitempty"` + ProvisionReady bool `json:"provision_ready,omitempty"` } func main() { @@ -148,6 +149,7 @@ func main() { mustBind(w, "importSubscription", a.importSubscription) mustBind(w, "saveRemnawave", a.saveRemnawave) mustBind(w, "importRemnawave", a.importRemnawave) + mustBind(w, "provisionUser", a.provisionUser) go a.autoCheckUpdate() @@ -198,26 +200,27 @@ func (a *app) getState() (uiState, error) { corePath, coreReady = path, true } out := uiState{ - Connected: st.Connected, - Profile: st.Profile, - ActiveProfile: active, - Protocol: string(st.Protocol), - HTTPProxy: st.HTTPProxy, - SOCKSProxy: st.SOCKSProxy, - SystemProxy: cfg.SystemProxy, - Proxy: proxy, - CoreReady: coreReady, - CorePath: corePath, - ConfigPath: a.cfgPath, - Profiles: cfg.ListProfiles(), - Version: update.DisplayVersion(), - Update: a.updateStatus, - Pings: append([]netcheck.Result(nil), a.pings...), - Subscription: cfg.SubscriptionURL, - SubInfo: a.mgr.SubscriptionInfo(), - Remnawave: a.mgr.RemnawaveSettings(), - Hy2: a.mgr.ActiveHy2Options(), - StorePackaged: update.IsStorePackaged(), + Connected: st.Connected, + Profile: st.Profile, + ActiveProfile: active, + Protocol: string(st.Protocol), + HTTPProxy: st.HTTPProxy, + SOCKSProxy: st.SOCKSProxy, + SystemProxy: cfg.SystemProxy, + Proxy: proxy, + CoreReady: coreReady, + CorePath: corePath, + ConfigPath: a.cfgPath, + Profiles: cfg.ListProfiles(), + Version: update.DisplayVersion(), + Update: a.updateStatus, + Pings: append([]netcheck.Result(nil), a.pings...), + Subscription: cfg.SubscriptionURL, + SubInfo: a.mgr.SubscriptionInfo(), + Remnawave: a.mgr.RemnawaveSettings(), + Hy2: a.mgr.ActiveHy2Options(), + StorePackaged: update.IsStorePackaged(), + ProvisionReady: a.mgr.ProvisionConfigured(), } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -341,6 +344,10 @@ func (a *app) importRemnawave(s remnawave.Settings) (core.ImportResult, error) { return a.mgr.ImportRemnawave(s) } +func (a *app) provisionUser(username string) (core.ProvisionOutcome, error) { + return a.mgr.ProvisionAccess(username) +} + type pingBestResult struct { Pings []netcheck.Result `json:"pings"` BestName string `json:"best_name,omitempty"` diff --git a/configs/remnawave-api.example.json b/configs/remnawave-api.example.json new file mode 100644 index 0000000..24cf6d3 --- /dev/null +++ b/configs/remnawave-api.example.json @@ -0,0 +1,11 @@ +{ + "panel_url": "https://panel.example.com", + "api_token": "PASTE_API_TOKEN_HERE", + "caddy_api_key": "", + "provision": { + "traffic_gb": 50, + "days": 30, + "strategy": "MONTH", + "hwid_device_limit": 0 + } +} diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index f25830d..ccf972b 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,16 +1,16 @@ { "version": "3.9.0", - "notes": "3.9.0+2: tolerant subscription import (skips broken entries), full server list from Remnawave subs, subscription info card (expiry / traffic / devices). Windows 3.9.0.2.", + "notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "os": "windows", "arch": "amd64" }, diff --git a/dist/navis-release/windows/Navis.exe b/dist/navis-release/windows/Navis.exe index ad37bc4..ba43683 100644 Binary files a/dist/navis-release/windows/Navis.exe and b/dist/navis-release/windows/Navis.exe differ diff --git a/dist/navis-release/windows/vpnclient.exe b/dist/navis-release/windows/vpnclient.exe index 8de84c4..143a91c 100644 Binary files a/dist/navis-release/windows/vpnclient.exe and b/dist/navis-release/windows/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index f25830d..ccf972b 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,16 +1,16 @@ { "version": "3.9.0", - "notes": "3.9.0+2: tolerant subscription import (skips broken entries), full server list from Remnawave subs, subscription info card (expiry / traffic / devices). Windows 3.9.0.2.", + "notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "os": "windows", "arch": "amd64" }, diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 5ac0c87..3ee9417 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -38,26 +38,27 @@ type App struct { } type UIState struct { - Connected bool `json:"connected"` - Profile string `json:"profile,omitempty"` - ActiveProfile string `json:"active_profile,omitempty"` - Protocol string `json:"protocol,omitempty"` - HTTPProxy string `json:"http_proxy,omitempty"` - SOCKSProxy string `json:"socks_proxy,omitempty"` - SystemProxy bool `json:"system_proxy"` - Proxy string `json:"proxy"` - CoreReady bool `json:"core_ready"` - CorePath string `json:"core_path,omitempty"` - ConfigPath string `json:"config_path"` - Profiles []config.ProfileInfo `json:"profiles"` - Version string `json:"version"` - Update update.Status `json:"update"` - Pings []netcheck.Result `json:"pings"` - Subscription string `json:"subscription_url"` - SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"` - Remnawave remnawave.Settings `json:"remnawave"` - Hy2 core.Hy2Options `json:"hy2"` - StorePackaged bool `json:"store_packaged,omitempty"` + Connected bool `json:"connected"` + Profile string `json:"profile,omitempty"` + ActiveProfile string `json:"active_profile,omitempty"` + Protocol string `json:"protocol,omitempty"` + HTTPProxy string `json:"http_proxy,omitempty"` + SOCKSProxy string `json:"socks_proxy,omitempty"` + SystemProxy bool `json:"system_proxy"` + Proxy string `json:"proxy"` + CoreReady bool `json:"core_ready"` + CorePath string `json:"core_path,omitempty"` + ConfigPath string `json:"config_path"` + Profiles []config.ProfileInfo `json:"profiles"` + Version string `json:"version"` + Update update.Status `json:"update"` + Pings []netcheck.Result `json:"pings"` + Subscription string `json:"subscription_url"` + SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"` + Remnawave remnawave.Settings `json:"remnawave"` + Hy2 core.Hy2Options `json:"hy2"` + StorePackaged bool `json:"store_packaged,omitempty"` + ProvisionReady bool `json:"provision_ready,omitempty"` } type PingBestResult struct { @@ -117,26 +118,27 @@ func (a *App) GetState() (UIState, error) { corePath, coreReady = path, true } out := UIState{ - Connected: st.Connected, - Profile: st.Profile, - ActiveProfile: active, - Protocol: string(st.Protocol), - HTTPProxy: st.HTTPProxy, - SOCKSProxy: st.SOCKSProxy, - SystemProxy: cfg.SystemProxy, - Proxy: proxy, - CoreReady: coreReady, - CorePath: corePath, - ConfigPath: a.CfgPath, - Profiles: cfg.ListProfiles(), - Version: update.DisplayVersion(), - Update: a.UpdateStatus, - Pings: append([]netcheck.Result(nil), a.Pings...), - Subscription: cfg.SubscriptionURL, - SubInfo: a.Mgr.SubscriptionInfo(), - Remnawave: a.Mgr.RemnawaveSettings(), - Hy2: a.Mgr.ActiveHy2Options(), - StorePackaged: update.IsStorePackaged(), + Connected: st.Connected, + Profile: st.Profile, + ActiveProfile: active, + Protocol: string(st.Protocol), + HTTPProxy: st.HTTPProxy, + SOCKSProxy: st.SOCKSProxy, + SystemProxy: cfg.SystemProxy, + Proxy: proxy, + CoreReady: coreReady, + CorePath: corePath, + ConfigPath: a.CfgPath, + Profiles: cfg.ListProfiles(), + Version: update.DisplayVersion(), + Update: a.UpdateStatus, + Pings: append([]netcheck.Result(nil), a.Pings...), + Subscription: cfg.SubscriptionURL, + SubInfo: a.Mgr.SubscriptionInfo(), + Remnawave: a.Mgr.RemnawaveSettings(), + Hy2: a.Mgr.ActiveHy2Options(), + StorePackaged: update.IsStorePackaged(), + ProvisionReady: a.Mgr.ProvisionConfigured(), } if out.Protocol == "" { if p, err := cfg.ActiveProfile(); err == nil { @@ -253,6 +255,10 @@ func (a *App) ImportRemnawave(s remnawave.Settings) (core.ImportResult, error) { return a.Mgr.ImportRemnawave(s) } +func (a *App) ProvisionUser(username string) (core.ProvisionOutcome, error) { + return a.Mgr.ProvisionAccess(username) +} + func (a *App) PingServers() ([]netcheck.Result, error) { return a.runPings() } @@ -511,6 +517,8 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { _ = json.Unmarshal(args[0], &s) } return a.ImportRemnawave(s) + case "provisionUser": + return a.ProvisionUser(arg(args, 0, "")) case "quit": go func() { time.Sleep(200 * time.Millisecond) diff --git a/internal/appui/index.html b/internal/appui/index.html index 1e1b621..4575d50 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -1026,6 +1026,15 @@ + @@ -1158,7 +1167,7 @@ const methods = [ "getState","connect","disconnect","connectProfile","saveProfile","createProfile", "selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest", - "checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave","quit" + "checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave","provisionUser","quit" ]; if (typeof window.getState === "function") return; window.__navisHttp = true; @@ -1266,6 +1275,9 @@ const rwCaddy = $("rwCaddy"); const rwSaveBtn = $("rwSaveBtn"); const rwSyncBtn = $("rwSyncBtn"); + const provisionBox = $("provisionBox"); + const provUser = $("provUser"); + const provBtn = $("provBtn"); const hero = $("hero"); const protoChip = $("protoChip"); const heroHint = $("heroHint"); @@ -1618,6 +1630,9 @@ if (rwCaddy) rwCaddy.disabled = busy; if (rwSaveBtn) rwSaveBtn.disabled = busy; if (rwSyncBtn) rwSyncBtn.disabled = busy; + if (provisionBox) provisionBox.hidden = !state.provision_ready; + if (provUser) provUser.disabled = busy; + if (provBtn) provBtn.disabled = busy; btn.disabled = busy; rememberPings(state.pings || []); @@ -1696,7 +1711,7 @@ } function paintButtonsLocked(locked) { - [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl, rwBase, rwShort, rwToken, rwCaddy, rwSaveBtn, rwSyncBtn].forEach((b) => { + [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl, rwBase, rwShort, rwToken, rwCaddy, rwSaveBtn, rwSyncBtn, provUser, provBtn].forEach((b) => { if (b) b.disabled = locked; }); } @@ -1869,6 +1884,35 @@ })); } + if (provBtn) { + provBtn.addEventListener("click", () => withBusy(async () => { + try { + const u = (provUser.value || "").trim(); + if (!u) { + setMeta("Укажите имя пользователя (username / @telegram / email)", "err"); + return; + } + setMeta("Выдача доступа через панель…"); + const r = await provisionUser(u); + formHydrated = false; + dirty = false; + let msg = (r.created ? "Создан пользователь " : "Найден пользователь ") + 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 (_) {} + } + msg += " · импортировано серверов: " + (r.imported || 0); + setMeta(msg, "ok"); + } catch (e) { setMeta(String(e), "err"); } + })); + provUser.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + provBtn.click(); + } + }); + } + pingBtn.addEventListener("click", () => withBusy(async () => { try { setMeta("Пинг серверов…"); diff --git a/internal/core/manager.go b/internal/core/manager.go index 0cf64e1..0000eff 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -221,6 +221,13 @@ func (m *Manager) SetSystemProxy(enabled bool) { } 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() @@ -253,6 +260,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() { @@ -279,6 +291,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() { @@ -576,6 +593,61 @@ func (m *Manager) applySubscription(ctx context.Context, res *subscription.Resul return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil } +// ProvisionOutcome is what the UI shows after issuing access via the panel. +type ProvisionOutcome struct { + Username string `json:"username"` + Created bool `json:"created"` + SubscriptionURL string `json:"subscription_url"` + TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"` + ExpireAt int64 `json:"expire_at,omitempty"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` +} + +// ProvisionConfigured reports whether configs/remnawave-api.json exists — +// the admin "Выдать доступ" panel is shown only in that case. +func (m *Manager) ProvisionConfigured() bool { + st, err := os.Stat(remnawave.AdminConfigPath(m.cfgPath)) + return err == nil && !st.IsDir() +} + +// ProvisionAccess creates (or finds) a panel user via the Remnawave admin API +// and imports its subscription configs into the app. +func (m *Manager) ProvisionAccess(username string) (ProvisionOutcome, error) { + cfg, err := remnawave.LoadAdminConfig(remnawave.AdminConfigPath(m.cfgPath)) + if err != nil { + return ProvisionOutcome{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + res, err := remnawave.ProvisionUser(ctx, cfg, username) + if err != nil { + return ProvisionOutcome{}, err + } + out := ProvisionOutcome{ + Username: res.Username, + Created: res.Created, + SubscriptionURL: res.SubscriptionURL, + TrafficLimitBytes: res.TrafficLimitBytes, + ExpireAt: res.ExpireAt, + } + subURL := res.SubscriptionURL + if subURL == "" && res.ShortUUID != "" { + subURL = remnawave.PublicSubURL(cfg.PanelURL, res.ShortUUID) + out.SubscriptionURL = subURL + } + if subURL == "" { + return out, fmt.Errorf("панель не вернула subscription URL для %s", res.Username) + } + 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 +} + func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo { if info == nil { return nil diff --git a/internal/core/manager_test.go b/internal/core/manager_test.go new file mode 100644 index 0000000..435b653 --- /dev/null +++ b/internal/core/manager_test.go @@ -0,0 +1,104 @@ +package core + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "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) + } +} + +func TestProvisionConfigured(t *testing.T) { + m := newTestManager(t) + if m.ProvisionConfigured() { + t.Fatal("should be false without remnawave-api.json") + } + path := filepath.Join(filepath.Dir(m.ConfigPath()), "remnawave-api.json") + if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if !m.ProvisionConfigured() { + t.Fatal("should be true with remnawave-api.json present") + } +} diff --git a/internal/geoflag/geoflag.go b/internal/geoflag/geoflag.go index 181988f..5b1b5f5 100644 --- a/internal/geoflag/geoflag.go +++ b/internal/geoflag/geoflag.go @@ -84,7 +84,7 @@ func emojiToISO(flag string) string { 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)) { + if unicode.IsLetter(rune('A' + a)) { return cc } return "" @@ -118,6 +118,49 @@ var countryNames = []nameCode{ {"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"}, + + // 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{}{ diff --git a/internal/geoflag/geoflag_test.go b/internal/geoflag/geoflag_test.go index 5bb02f6..7e8d2fc 100644 --- a/internal/geoflag/geoflag_test.go +++ b/internal/geoflag/geoflag_test.go @@ -11,6 +11,20 @@ func TestFromText(t *testing.T) { {"🇺🇸 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"}, } for _, c := range cases { cc, emoji := FromText(c.in) diff --git a/internal/linknorm/normalize.go b/internal/linknorm/normalize.go index 3966b2d..d13b797 100644 --- a/internal/linknorm/normalize.go +++ b/internal/linknorm/normalize.go @@ -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 diff --git a/internal/linknorm/normalize_test.go b/internal/linknorm/normalize_test.go new file mode 100644 index 0000000..068d5fe --- /dev/null +++ b/internal/linknorm/normalize_test.go @@ -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 subscription: %v", err) + } +} diff --git a/internal/remnawave/provision.go b/internal/remnawave/provision.go new file mode 100644 index 0000000..2bdf526 --- /dev/null +++ b/internal/remnawave/provision.go @@ -0,0 +1,276 @@ +package remnawave + +// Provisioning: the app itself can issue access via the Remnawave admin API +// (docs: https://docs.rw / TypeScript SDK commands): +// +// GET {base}/api/users/by-username/{username} (GetUserByUsernameCommand) +// POST {base}/api/users (CreateUserCommand) +// +// Auth: Authorization: Bearer (+ optional Caddy X-Api-Key). +// Credentials live in a separate config file configs/remnawave-api.json — +// never committed; see configs/remnawave-api.example.json. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// ProvisionOptions are plan defaults for newly created users. +type ProvisionOptions struct { + TrafficGB int64 `json:"traffic_gb"` // default 50 + Days int `json:"days"` // default 30 + Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH) + HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited +} + +// AdminConfig is loaded from configs/remnawave-api.json (admin credentials). +type AdminConfig struct { + PanelURL string `json:"panel_url"` + APIToken string `json:"api_token"` + CaddyAPIKey string `json:"caddy_api_key,omitempty"` + Provision ProvisionOptions `json:"provision"` +} + +// AdminConfigPath returns the expected path of remnawave-api.json — +// next to the main config.json (configs dir). +func AdminConfigPath(cfgPath string) string { + return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json") +} + +// LoadAdminConfig reads and validates the admin API config file. +func LoadAdminConfig(path string) (*AdminConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path)) + } + var cfg AdminConfig + if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil { + return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err) + } + cfg.PanelURL = NormalizeBase(cfg.PanelURL) + cfg.APIToken = strings.TrimSpace(cfg.APIToken) + cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey) + if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") { + return nil, fmt.Errorf("remnawave-api.json: укажите panel_url") + } + if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" { + return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)") + } + // Plan defaults: 50 GB / 30 days / MONTH reset. + if cfg.Provision.TrafficGB <= 0 { + cfg.Provision.TrafficGB = 50 + } + if cfg.Provision.Days <= 0 { + cfg.Provision.Days = 30 + } + switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) { + case "NO_RESET", "DAY", "WEEK", "MONTH": + cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) + default: + cfg.Provision.Strategy = "MONTH" + } + if cfg.Provision.HwidDeviceLimit < 0 { + cfg.Provision.HwidDeviceLimit = 0 + } + return &cfg, nil +} + +// ProvisionResult describes the created or found panel user. +type ProvisionResult struct { + Username string `json:"username"` + UUID string `json:"uuid"` + ShortUUID string `json:"short_uuid"` + SubscriptionURL string `json:"subscription_url"` + TrafficLimitBytes int64 `json:"traffic_limit_bytes"` + ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown + Created bool `json:"created"` +} + +var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`) + +// NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.) +// into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars. +func NormalizeUsername(raw string) (string, error) { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "@") + if i := strings.IndexByte(s, '@'); i > 0 { + s = s[:i] // email → local part + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + b.WriteRune(r) + case r == '.', r == ' ', r == '+': + b.WriteByte('_') + } + } + s = b.String() + for len(s) > 0 && len(s) < 6 { + s += "_vpn" // pad short handles ("ivan" → "ivan_vpn") + } + if len(s) > 34 { + s = s[:34] + } + if !reUsername.MatchString(s) { + return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s) + } + return s, nil +} + +// ProvisionUser finds an existing panel user by username or creates one with +// the configured plan (traffic_gb / days / strategy / hwid_device_limit), +// then returns its subscription URL for import. +func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) { + if cfg == nil { + return nil, fmt.Errorf("нет конфигурации remnawave-api") + } + username, err := NormalizeUsername(rawUsername) + if err != nil { + return nil, err + } + client := &http.Client{Timeout: 30 * time.Second} + + // 1) Existing user? + getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username) + status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil) + if err != nil { + return nil, err + } + switch { + case status == http.StatusOK: + res, err := parseProvisionUser(body) + if err != nil { + return nil, err + } + res.Username = username + return res, nil + case status == http.StatusNotFound: + // fall through to create + default: + return nil, adminHTTPError(status, body) + } + + // 2) Create with plan defaults. + payload := map[string]any{ + "username": username, + "status": "ACTIVE", + "trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024, + "trafficLimitStrategy": cfg.Provision.Strategy, + "expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339), + } + if cfg.Provision.HwidDeviceLimit > 0 { + payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit + } + raw, _ := json.Marshal(payload) + status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw) + if err != nil { + return nil, err + } + if status != http.StatusOK && status != http.StatusCreated { + return nil, adminHTTPError(status, body) + } + res, err := parseProvisionUser(body) + if err != nil { + return nil, err + } + res.Username = username + res.Created = true + return res, nil +} + +func adminDo(ctx context.Context, client *http.Client, cfg *AdminConfig, method, endpoint string, body []byte) (int, string, error) { + var rd io.Reader + if body != nil { + rd = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, rd) + if err != nil { + return 0, "", err + } + req.Header.Set("User-Agent", "Navis/3.9 (Remnawave provision)") + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+cfg.APIToken) + if cfg.CaddyAPIKey != "" { + req.Header.Set("X-Api-Key", cfg.CaddyAPIKey) + } + if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) { + req.Header.Set("X-Forwarded-Proto", u.Scheme) + req.Header.Set("X-Forwarded-For", "127.0.0.1") + } + resp, err := client.Do(req) + if err != nil { + return 0, "", fmt.Errorf("Remnawave API недоступен: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return resp.StatusCode, "", err + } + return resp.StatusCode, string(raw), nil +} + +func adminHTTPError(status int, body string) error { + msg := strings.TrimSpace(body) + if len(msg) > 160 { + msg = msg[:160] + "…" + } + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("Неверный API ключ (HTTP %d) — проверьте api_token в remnawave-api.json", status) + case http.StatusConflict: + return fmt.Errorf("Пользователь уже существует, но панель вернула конфликт (HTTP 409): %s", msg) + case http.StatusBadRequest: + return fmt.Errorf("Панель отклонила запрос (HTTP 400): %s", msg) + default: + return fmt.Errorf("Remnawave HTTP %d: %s", status, msg) + } +} + +func parseProvisionUser(body string) (*ProvisionResult, error) { + var payload struct { + Response provisionUserDTO `json:"response"` + } + if err := json.Unmarshal([]byte(body), &payload); err != nil || payload.Response.UUID == "" { + // Some deployments return the DTO without the envelope. + var direct provisionUserDTO + if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil || direct.UUID == "" { + return nil, fmt.Errorf("Remnawave: не удалось разобрать ответ панели") + } + payload.Response = direct + } + u := payload.Response + res := &ProvisionResult{ + UUID: u.UUID, + ShortUUID: u.ShortUUID, + SubscriptionURL: strings.TrimSpace(u.SubscriptionURL), + TrafficLimitBytes: u.TrafficLimitBytes, + } + if u.ExpireAt != "" { + if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil { + res.ExpireAt = t.Unix() + } + } + return res, nil +} + +type provisionUserDTO struct { + UUID string `json:"uuid"` + ShortUUID string `json:"shortUuid"` + SubscriptionURL string `json:"subscriptionUrl"` + TrafficLimitBytes int64 `json:"trafficLimitBytes"` + ExpireAt string `json:"expireAt"` +} diff --git a/internal/remnawave/provision_test.go b/internal/remnawave/provision_test.go new file mode 100644 index 0000000..8cec1e4 --- /dev/null +++ b/internal/remnawave/provision_test.go @@ -0,0 +1,192 @@ +package remnawave + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testAdminConfig(baseURL string) *AdminConfig { + return &AdminConfig{ + PanelURL: baseURL, + APIToken: "test-token", + Provision: ProvisionOptions{ + TrafficGB: 50, + Days: 30, + Strategy: "MONTH", + }, + } +} + +func TestProvisionUserExisting(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/api/users/by-username/ivan_petrov" { + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{ + "uuid": "u-1", + "shortUuid": "short1", + "subscriptionUrl": "https://sub.example.com/short1", + "trafficLimitBytes": int64(50) << 30, + "expireAt": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + }}) + return + } + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "ivan_petrov") + if err != nil { + t.Fatal(err) + } + if res.Created { + t.Fatal("expected existing user, got created") + } + if res.SubscriptionURL != "https://sub.example.com/short1" { + t.Fatalf("subscription url: %q", res.SubscriptionURL) + } + if res.Username != "ivan_petrov" { + t.Fatalf("username: %q", res.Username) + } +} + +func TestProvisionUserCreate(t *testing.T) { + var created map[string]any + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/users/by-username/"): + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost && r.URL.Path == "/api/users": + if err := json.NewDecoder(r.Body).Decode(&created); err != nil { + t.Errorf("decode create body: %v", err) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{ + "uuid": "u-2", + "shortUuid": "short2", + "subscriptionUrl": "https://sub.example.com/short2", + "trafficLimitBytes": created["trafficLimitBytes"], + "expireAt": created["expireAt"], + }}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + } + })) + defer ts.Close() + + res, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "@new_client") + if err != nil { + t.Fatal(err) + } + if !res.Created { + t.Fatal("expected created user") + } + if res.Username != "new_client" { + t.Fatalf("username: %q", res.Username) + } + if got := created["trafficLimitBytes"].(float64); int64(got) != 50<<30 { + t.Fatalf("trafficLimitBytes = %v, want %d (50 GB)", got, int64(50)<<30) + } + if created["trafficLimitStrategy"] != "MONTH" { + t.Fatalf("strategy = %v", created["trafficLimitStrategy"]) + } + if created["status"] != "ACTIVE" { + t.Fatalf("status = %v", created["status"]) + } + expireStr, _ := created["expireAt"].(string) + expire, err := time.Parse(time.RFC3339, expireStr) + if err != nil { + t.Fatalf("expireAt %q: %v", expireStr, err) + } + days := time.Until(expire).Hours() / 24 + if days < 29 || days > 31 { + t.Fatalf("expireAt %.1f days from now, want ~30", days) + } +} + +func TestProvisionUserBadToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer ts.Close() + + _, err := ProvisionUser(context.Background(), testAdminConfig(ts.URL), "somebody1") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Неверный API ключ") { + t.Fatalf("error = %v, want «Неверный API ключ»", err) + } +} + +func TestNormalizeUsername(t *testing.T) { + cases := []struct { + in, want string + wantErr bool + }{ + {"ivan_petrov", "ivan_petrov", false}, + {"@tg_handle", "tg_handle", false}, + {"user@example.com", "user_vpn", false}, + {"Иван", "", true}, + {"ab", "ab_vpn", false}, // padded to min length + } + for _, c := range cases { + got, err := NormalizeUsername(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("%q: expected error, got %q", c.in, got) + } + continue + } + if err != nil { + t.Fatalf("%q: %v", c.in, err) + } + if got != c.want { + t.Fatalf("%q: got %q want %q", c.in, got, c.want) + } + } +} + +func TestLoadAdminConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "remnawave-api.json") + + if _, err := LoadAdminConfig(path); err == nil { + t.Fatal("expected error for missing file") + } + + body := `{"panel_url":"https://panel.test/","api_token":"tok123","provision":{}}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadAdminConfig(path) + if err != nil { + t.Fatal(err) + } + if cfg.PanelURL != "https://panel.test" { + t.Fatalf("panel url: %q", cfg.PanelURL) + } + if cfg.Provision.TrafficGB != 50 || cfg.Provision.Days != 30 || cfg.Provision.Strategy != "MONTH" { + t.Fatalf("defaults not applied: %+v", cfg.Provision) + } + + // Placeholder token must be rejected. + body = `{"panel_url":"https://panel.test","api_token":"PASTE_API_TOKEN_HERE"}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadAdminConfig(path); err == nil { + t.Fatal("expected error for placeholder token") + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 9508b5d..c058267 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -22,7 +22,7 @@ const CurrentVersion = "3.9.0" // 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 = 2 +const BuildNumber = 3 // 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" @@ -51,15 +51,15 @@ type PlatformAsset struct { // Manifest describes a remote release (multi-platform + legacy single URL). type Manifest struct { - Version string `json:"version"` - URL string `json:"url,omitempty"` // legacy windows-amd64 - SHA256 string `json:"sha256,omitempty"` - Notes string `json:"notes,omitempty"` - Mandatory bool `json:"mandatory,omitempty"` - Platform string `json:"platform,omitempty"` // legacy - OS string `json:"os,omitempty"` - Arch string `json:"arch,omitempty"` - Platforms map[string]PlatformAsset `json:"platforms,omitempty"` + Version string `json:"version"` + URL string `json:"url,omitempty"` // legacy windows-amd64 + SHA256 string `json:"sha256,omitempty"` + Notes string `json:"notes,omitempty"` + Mandatory bool `json:"mandatory,omitempty"` + Platform string `json:"platform,omitempty"` // legacy + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + Platforms map[string]PlatformAsset `json:"platforms,omitempty"` } // Status is returned to the UI / CLI. diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml index e635f92..e4f1f50 100644 --- a/packaging/msix/AppxManifest.xml +++ b/packaging/msix/AppxManifest.xml @@ -33,7 +33,7 @@ diff --git a/scripts/pack-msix.ps1 b/scripts/pack-msix.ps1 index 0b7739e..d7b701a 100644 --- a/scripts/pack-msix.ps1 +++ b/scripts/pack-msix.ps1 @@ -15,7 +15,7 @@ param( [string]$ExePath = "", [string]$OutDir = "", - [string]$Version = "3.9.0.2", + [string]$Version = "3.9.0.3", [string]$Name = "EvilFox.Navis", [string]$Publisher = "CN=EvilFox", [string]$DisplayName = "Navis", @@ -82,7 +82,7 @@ $manifestText = Get-Content -LiteralPath $ManifestSrc -Raw -Encoding UTF8 # Prefer exact placeholder swaps so we never touch . $manifestText = $manifestText.Replace('Name="EvilFox.Navis"', "Name=`"$Name`"") $manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"") -$manifestText = $manifestText.Replace('Version="3.9.0.2"', "Version=`"$Version`"") +$manifestText = $manifestText.Replace('Version="3.9.0.3"', "Version=`"$Version`"") $manifestText = $manifestText.Replace('Navis', "$DisplayName") $manifestText = $manifestText.Replace('EvilFox', "$PublisherDisplayName") $manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"") diff --git a/server/update.json b/server/update.json index f25830d..ccf972b 100644 --- a/server/update.json +++ b/server/update.json @@ -1,16 +1,16 @@ { "version": "3.9.0", - "notes": "3.9.0+2: tolerant subscription import (skips broken entries), full server list from Remnawave subs, subscription info card (expiry / traffic / devices). Windows 3.9.0.2.", + "notes": "3.9.0+3: fix pasting a subscription URL into the server-link field (no more 'proxy URI missing username'), RU country names + city hints for emoji flags, admin provisioning via Remnawave API («Выдать доступ», configs/remnawave-api.json). Windows 3.9.0.3.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe", - "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7", + "sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d", "os": "windows", "arch": "amd64" }, diff --git a/versioninfo.json b/versioninfo.json index 36a0815..f92bfae 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 2 }, - "ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 2 }, + "FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 3 }, + "ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 3 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -11,12 +11,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "3.9.0.2", + "FileVersion": "3.9.0.3", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "3.9.0.2", + "ProductVersion": "3.9.0.3", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {