Release 3.9.0+3: subscription-URL routing, emoji flags, Remnawave provisioning.

Route credential-less http(s) URLs pasted into the add-link field to
subscription import (fixes remaining 'proxy URI missing username').
Extend geoflag with RU country names and city hints; live Remnawave
names already carrying emoji flags are kept as-is. Add admin
provisioning via configs/remnawave-api.json (GET by-username / POST
users, 50 GB MONTH plan) and the «Выдать доступ» UI panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:36:54 +03:00
co-authored by Cursor
parent e34312ef9c
commit 77bd7da861
24 changed files with 974 additions and 110 deletions
+2
View File
@@ -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
+23
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="3.9.0.1"
version="3.9.0.3"
processorArchitecture="*"
name="EvilFox.Navis"
type="win32"/>
+47 -40
View File
@@ -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"`
+11
View File
@@ -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
}
}
+3 -3
View File
@@ -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"
},
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -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"
},
+48 -40
View File
@@ -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)
+46 -2
View File
@@ -1026,6 +1026,15 @@
<button class="action primary" id="rwSyncBtn" type="button" style="flex:1">Синхронизировать</button>
</div>
</div>
<div class="remna-box" id="provisionBox" hidden>
<div class="remna-title">Выдать доступ</div>
<p class="remna-hint">Создаёт пользователя в панели Remnawave (тариф из configs/remnawave-api.json, по умолчанию 50 ГБ / 30 дней) и сразу импортирует его конфиги.</p>
<div>
<label class="field" for="provUser">Пользователь</label>
<input id="provUser" type="text" placeholder="username / @telegram / email" spellcheck="false" autocomplete="off" />
</div>
<button class="action primary" id="provBtn" type="button">Выдать доступ</button>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</details>
@@ -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("Пинг серверов…");
+72
View File
@@ -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
+104
View File
@@ -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")
}
}
+44 -1
View File
@@ -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{}{
+14
View File
@@ -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)
+24
View File
@@ -2,6 +2,7 @@ package linknorm
import (
"fmt"
"net/url"
"strings"
"vpnclient/internal/config"
@@ -11,6 +12,23 @@ import (
"vpnclient/internal/protocols/xray"
)
// LooksLikeSubscriptionURL reports whether raw is a plain http(s) URL without
// userinfo — i.e. a subscription URL rather than a naive proxy share link
// (which always carries user:pass@). Callers should route such input into the
// subscription import flow instead of the proxy-URI parser.
func LooksLikeSubscriptionURL(raw string) bool {
raw = strings.TrimSpace(raw)
lower := strings.ToLower(raw)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
return u.User == nil
}
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
raw = strings.TrimSpace(raw)
@@ -42,6 +60,12 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
u, rem, err := awg.NormalizeShareLink(raw)
return u, config.ProtocolAWG, rem, err
}
// Safety net: a bare http(s) URL without user:pass@ is a subscription
// URL, not a naive proxy link — callers should have routed it to the
// subscription import flow (see LooksLikeSubscriptionURL).
if LooksLikeSubscriptionURL(raw) {
return "", config.ProtocolNaive, "", fmt.Errorf("это ссылка подписки (нет логина и пароля) — вставьте её в поле «URL подписки» или сохраните ещё раз: импорт запустится автоматически")
}
if xray.Detect(raw) {
u, rem, err := xray.NormalizeShareLink(raw)
return u, xray.DetectProtocol(raw), rem, err
+44
View File
@@ -0,0 +1,44 @@
package linknorm
import (
"strings"
"testing"
)
func TestLooksLikeSubscriptionURL(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"https://api.evilfox.win/ZKszcVcC3xbWb8qj", true},
{"http://panel.example.com/api/sub/abc", true},
{" https://api.evilfox.win/ZKszcVcC3xbWb8qj ", true},
{"https://user:pass@host:443", false}, // naive proxy link
{"https://user@host:443", false}, // has userinfo
{"quic://user:pass@host", false},
{"vless://uuid@host:443", false},
{"naive+https://user:pass@host:443", false},
{"not a url", false},
{"", false},
}
for _, c := range cases {
if got := LooksLikeSubscriptionURL(c.in); got != c.want {
t.Fatalf("%q: got %v want %v", c.in, got, c.want)
}
}
}
// Direct Normalize of a credential-less https URL must never surface the raw
// "proxy URI missing username" error — it explains this is a subscription URL.
func TestNormalizeSubscriptionURLError(t *testing.T) {
_, _, _, err := Normalize("", "https://api.evilfox.win/ZKszcVcC3xbWb8qj")
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "missing username") {
t.Fatalf("raw parser error leaked: %v", err)
}
if !strings.Contains(err.Error(), "подписк") {
t.Fatalf("error should mention subscription: %v", err)
}
}
+276
View File
@@ -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 <admin API token> (+ optional Caddy X-Api-Key).
// Credentials live in a separate config file configs/remnawave-api.json —
// never committed; see configs/remnawave-api.example.json.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// ProvisionOptions are plan defaults for newly created users.
type ProvisionOptions struct {
TrafficGB int64 `json:"traffic_gb"` // default 50
Days int `json:"days"` // default 30
Strategy string `json:"strategy"` // NO_RESET | DAY | WEEK | MONTH (default MONTH)
HwidDeviceLimit int `json:"hwid_device_limit"` // 0 = unlimited
}
// AdminConfig is loaded from configs/remnawave-api.json (admin credentials).
type AdminConfig struct {
PanelURL string `json:"panel_url"`
APIToken string `json:"api_token"`
CaddyAPIKey string `json:"caddy_api_key,omitempty"`
Provision ProvisionOptions `json:"provision"`
}
// AdminConfigPath returns the expected path of remnawave-api.json —
// next to the main config.json (configs dir).
func AdminConfigPath(cfgPath string) string {
return filepath.Join(filepath.Dir(cfgPath), "remnawave-api.json")
}
// LoadAdminConfig reads and validates the admin API config file.
func LoadAdminConfig(path string) (*AdminConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("нет файла %s (скопируйте remnawave-api.example.json и вставьте ключ)", filepath.Base(path))
}
var cfg AdminConfig
if err := json.Unmarshal(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}), &cfg); err != nil {
return nil, fmt.Errorf("remnawave-api.json: некорректный JSON: %w", err)
}
cfg.PanelURL = NormalizeBase(cfg.PanelURL)
cfg.APIToken = strings.TrimSpace(cfg.APIToken)
cfg.CaddyAPIKey = strings.TrimSpace(cfg.CaddyAPIKey)
if cfg.PanelURL == "" || strings.Contains(cfg.PanelURL, "panel.example.com") {
return nil, fmt.Errorf("remnawave-api.json: укажите panel_url")
}
if cfg.APIToken == "" || cfg.APIToken == "PASTE_API_TOKEN_HERE" {
return nil, fmt.Errorf("remnawave-api.json: укажите api_token (панель → API Tokens)")
}
// Plan defaults: 50 GB / 30 days / MONTH reset.
if cfg.Provision.TrafficGB <= 0 {
cfg.Provision.TrafficGB = 50
}
if cfg.Provision.Days <= 0 {
cfg.Provision.Days = 30
}
switch strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy)) {
case "NO_RESET", "DAY", "WEEK", "MONTH":
cfg.Provision.Strategy = strings.ToUpper(strings.TrimSpace(cfg.Provision.Strategy))
default:
cfg.Provision.Strategy = "MONTH"
}
if cfg.Provision.HwidDeviceLimit < 0 {
cfg.Provision.HwidDeviceLimit = 0
}
return &cfg, nil
}
// ProvisionResult describes the created or found panel user.
type ProvisionResult struct {
Username string `json:"username"`
UUID string `json:"uuid"`
ShortUUID string `json:"short_uuid"`
SubscriptionURL string `json:"subscription_url"`
TrafficLimitBytes int64 `json:"traffic_limit_bytes"`
ExpireAt int64 `json:"expire_at"` // unix seconds; 0 = unknown
Created bool `json:"created"`
}
var reUsername = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,34}$`)
// NormalizeUsername sanitizes free-form input (telegram @handle, email, etc.)
// into a Remnawave-compatible username: [a-zA-Z0-9_-], 6..34 chars.
func NormalizeUsername(raw string) (string, error) {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "@")
if i := strings.IndexByte(s, '@'); i > 0 {
s = s[:i] // email → local part
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
case r == '.', r == ' ', r == '+':
b.WriteByte('_')
}
}
s = b.String()
for len(s) > 0 && len(s) < 6 {
s += "_vpn" // pad short handles ("ivan" → "ivan_vpn")
}
if len(s) > 34 {
s = s[:34]
}
if !reUsername.MatchString(s) {
return "", fmt.Errorf("имя пользователя: 6–34 символа, латиница/цифры/_/- (получилось %q)", s)
}
return s, nil
}
// ProvisionUser finds an existing panel user by username or creates one with
// the configured plan (traffic_gb / days / strategy / hwid_device_limit),
// then returns its subscription URL for import.
func ProvisionUser(ctx context.Context, cfg *AdminConfig, rawUsername string) (*ProvisionResult, error) {
if cfg == nil {
return nil, fmt.Errorf("нет конфигурации remnawave-api")
}
username, err := NormalizeUsername(rawUsername)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
// 1) Existing user?
getURL := cfg.PanelURL + "/api/users/by-username/" + url.PathEscape(username)
status, body, err := adminDo(ctx, client, cfg, http.MethodGet, getURL, nil)
if err != nil {
return nil, err
}
switch {
case status == http.StatusOK:
res, err := parseProvisionUser(body)
if err != nil {
return nil, err
}
res.Username = username
return res, nil
case status == http.StatusNotFound:
// fall through to create
default:
return nil, adminHTTPError(status, body)
}
// 2) Create with plan defaults.
payload := map[string]any{
"username": username,
"status": "ACTIVE",
"trafficLimitBytes": cfg.Provision.TrafficGB * 1024 * 1024 * 1024,
"trafficLimitStrategy": cfg.Provision.Strategy,
"expireAt": time.Now().UTC().AddDate(0, 0, cfg.Provision.Days).Format(time.RFC3339),
}
if cfg.Provision.HwidDeviceLimit > 0 {
payload["hwidDeviceLimit"] = cfg.Provision.HwidDeviceLimit
}
raw, _ := json.Marshal(payload)
status, body, err = adminDo(ctx, client, cfg, http.MethodPost, cfg.PanelURL+"/api/users", raw)
if err != nil {
return nil, err
}
if status != http.StatusOK && status != http.StatusCreated {
return nil, adminHTTPError(status, body)
}
res, err := parseProvisionUser(body)
if err != nil {
return nil, err
}
res.Username = username
res.Created = true
return res, nil
}
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"`
}
+192
View File
@@ -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")
}
}
+10 -10
View File
@@ -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.
+1 -1
View File
@@ -33,7 +33,7 @@
<Identity
Name="EvilFox.Navis"
Publisher="CN=EvilFox"
Version="3.9.0.2"
Version="3.9.0.3"
ProcessorArchitecture="x64" />
<Properties>
+2 -2
View File
@@ -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 <?xml version=...?>.
$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('<DisplayName>Navis</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
$manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"")
+3 -3
View File
@@ -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"
},
+4 -4
View File
@@ -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": {