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:
@@ -4,6 +4,8 @@ bin/
|
|||||||
!dist/**/*.exe
|
!dist/**/*.exe
|
||||||
!dist/**/
|
!dist/**/
|
||||||
configs/config.json
|
configs/config.json
|
||||||
|
configs/remnawave-api.json
|
||||||
|
dist/navis-release/windows/configs/
|
||||||
configs/.navis-webview/
|
configs/.navis-webview/
|
||||||
cmd/vpnapp/resource.syso
|
cmd/vpnapp/resource.syso
|
||||||
*.syso
|
*.syso
|
||||||
|
|||||||
@@ -230,6 +230,29 @@ build.bat
|
|||||||
|
|
||||||
Токены хранятся только локально в `configs/config.json` (не коммитьте).
|
Токены хранятся только локально в `configs/config.json` (не коммитьте).
|
||||||
|
|
||||||
|
### Выдача доступа из приложения (admin API)
|
||||||
|
|
||||||
|
Приложение умеет само создавать пользователей в панели Remnawave и сразу импортировать их конфиги (панель «Выдать доступ» в интерфейсе). Для этого нужен **отдельный** файл с админ-ключом:
|
||||||
|
|
||||||
|
1. Скопируйте `configs/remnawave-api.example.json` → `configs/remnawave-api.json`.
|
||||||
|
2. Вставьте `panel_url` и `api_token` (панель → API Tokens). `caddy_api_key` — только если панель за Caddy-auth.
|
||||||
|
3. Перезапустите Navis — появится панель «Выдать доступ».
|
||||||
|
|
||||||
|
Файл читается **в рантайме** из папки `configs` рядом с `config.json` — пересборка (repack) не требуется, достаточно положить файл и перезапустить приложение.
|
||||||
|
|
||||||
|
Тариф по умолчанию (настраивается в блоке `provision`):
|
||||||
|
|
||||||
|
| Поле | Значение по умолчанию |
|
||||||
|
|------|----------------------|
|
||||||
|
| `traffic_gb` | 50 ГБ |
|
||||||
|
| `days` | 30 дней (expireAt = сейчас + days) |
|
||||||
|
| `strategy` | `MONTH` (сброс трафика; также `NO_RESET` / `DAY` / `WEEK`) |
|
||||||
|
| `hwid_device_limit` | 0 = без лимита устройств |
|
||||||
|
|
||||||
|
Логика: `GET /api/users/by-username/{username}` — если пользователь есть, берётся его `subscriptionUrl`; иначе `POST /api/users` с тарифом выше, затем подписка импортируется в приложение автоматически.
|
||||||
|
|
||||||
|
`configs/remnawave-api.json` в `.gitignore` — **никогда не коммитьте** реальный ключ, в репозитории только `remnawave-api.example.json`.
|
||||||
|
|
||||||
## Сборка macOS (кросс с Windows / Linux)
|
## Сборка macOS (кросс с Windows / Linux)
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
<assemblyIdentity
|
<assemblyIdentity
|
||||||
version="3.9.0.1"
|
version="3.9.0.3"
|
||||||
processorArchitecture="*"
|
processorArchitecture="*"
|
||||||
name="EvilFox.Navis"
|
name="EvilFox.Navis"
|
||||||
type="win32"/>
|
type="win32"/>
|
||||||
|
|||||||
+47
-40
@@ -40,26 +40,27 @@ type app struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type uiState struct {
|
type uiState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Profile string `json:"profile,omitempty"`
|
Profile string `json:"profile,omitempty"`
|
||||||
ActiveProfile string `json:"active_profile,omitempty"`
|
ActiveProfile string `json:"active_profile,omitempty"`
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||||
SystemProxy bool `json:"system_proxy"`
|
SystemProxy bool `json:"system_proxy"`
|
||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
CoreReady bool `json:"core_ready"`
|
CoreReady bool `json:"core_ready"`
|
||||||
CorePath string `json:"core_path,omitempty"`
|
CorePath string `json:"core_path,omitempty"`
|
||||||
ConfigPath string `json:"config_path"`
|
ConfigPath string `json:"config_path"`
|
||||||
Profiles []config.ProfileInfo `json:"profiles"`
|
Profiles []config.ProfileInfo `json:"profiles"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Update update.Status `json:"update"`
|
Update update.Status `json:"update"`
|
||||||
Pings []netcheck.Result `json:"pings"`
|
Pings []netcheck.Result `json:"pings"`
|
||||||
Subscription string `json:"subscription_url"`
|
Subscription string `json:"subscription_url"`
|
||||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||||
Remnawave remnawave.Settings `json:"remnawave"`
|
Remnawave remnawave.Settings `json:"remnawave"`
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
|
ProvisionReady bool `json:"provision_ready,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -148,6 +149,7 @@ func main() {
|
|||||||
mustBind(w, "importSubscription", a.importSubscription)
|
mustBind(w, "importSubscription", a.importSubscription)
|
||||||
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
mustBind(w, "saveRemnawave", a.saveRemnawave)
|
||||||
mustBind(w, "importRemnawave", a.importRemnawave)
|
mustBind(w, "importRemnawave", a.importRemnawave)
|
||||||
|
mustBind(w, "provisionUser", a.provisionUser)
|
||||||
|
|
||||||
go a.autoCheckUpdate()
|
go a.autoCheckUpdate()
|
||||||
|
|
||||||
@@ -198,26 +200,27 @@ func (a *app) getState() (uiState, error) {
|
|||||||
corePath, coreReady = path, true
|
corePath, coreReady = path, true
|
||||||
}
|
}
|
||||||
out := uiState{
|
out := uiState{
|
||||||
Connected: st.Connected,
|
Connected: st.Connected,
|
||||||
Profile: st.Profile,
|
Profile: st.Profile,
|
||||||
ActiveProfile: active,
|
ActiveProfile: active,
|
||||||
Protocol: string(st.Protocol),
|
Protocol: string(st.Protocol),
|
||||||
HTTPProxy: st.HTTPProxy,
|
HTTPProxy: st.HTTPProxy,
|
||||||
SOCKSProxy: st.SOCKSProxy,
|
SOCKSProxy: st.SOCKSProxy,
|
||||||
SystemProxy: cfg.SystemProxy,
|
SystemProxy: cfg.SystemProxy,
|
||||||
Proxy: proxy,
|
Proxy: proxy,
|
||||||
CoreReady: coreReady,
|
CoreReady: coreReady,
|
||||||
CorePath: corePath,
|
CorePath: corePath,
|
||||||
ConfigPath: a.cfgPath,
|
ConfigPath: a.cfgPath,
|
||||||
Profiles: cfg.ListProfiles(),
|
Profiles: cfg.ListProfiles(),
|
||||||
Version: update.DisplayVersion(),
|
Version: update.DisplayVersion(),
|
||||||
Update: a.updateStatus,
|
Update: a.updateStatus,
|
||||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||||
Subscription: cfg.SubscriptionURL,
|
Subscription: cfg.SubscriptionURL,
|
||||||
SubInfo: a.mgr.SubscriptionInfo(),
|
SubInfo: a.mgr.SubscriptionInfo(),
|
||||||
Remnawave: a.mgr.RemnawaveSettings(),
|
Remnawave: a.mgr.RemnawaveSettings(),
|
||||||
Hy2: a.mgr.ActiveHy2Options(),
|
Hy2: a.mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
|
ProvisionReady: a.mgr.ProvisionConfigured(),
|
||||||
}
|
}
|
||||||
if out.Protocol == "" {
|
if out.Protocol == "" {
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
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)
|
return a.mgr.ImportRemnawave(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) provisionUser(username string) (core.ProvisionOutcome, error) {
|
||||||
|
return a.mgr.ProvisionAccess(username)
|
||||||
|
}
|
||||||
|
|
||||||
type pingBestResult struct {
|
type pingBestResult struct {
|
||||||
Pings []netcheck.Result `json:"pings"`
|
Pings []netcheck.Result `json:"pings"`
|
||||||
BestName string `json:"best_name,omitempty"`
|
BestName string `json:"best_name,omitempty"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+48
-40
@@ -38,26 +38,27 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UIState struct {
|
type UIState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Profile string `json:"profile,omitempty"`
|
Profile string `json:"profile,omitempty"`
|
||||||
ActiveProfile string `json:"active_profile,omitempty"`
|
ActiveProfile string `json:"active_profile,omitempty"`
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||||
SystemProxy bool `json:"system_proxy"`
|
SystemProxy bool `json:"system_proxy"`
|
||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
CoreReady bool `json:"core_ready"`
|
CoreReady bool `json:"core_ready"`
|
||||||
CorePath string `json:"core_path,omitempty"`
|
CorePath string `json:"core_path,omitempty"`
|
||||||
ConfigPath string `json:"config_path"`
|
ConfigPath string `json:"config_path"`
|
||||||
Profiles []config.ProfileInfo `json:"profiles"`
|
Profiles []config.ProfileInfo `json:"profiles"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Update update.Status `json:"update"`
|
Update update.Status `json:"update"`
|
||||||
Pings []netcheck.Result `json:"pings"`
|
Pings []netcheck.Result `json:"pings"`
|
||||||
Subscription string `json:"subscription_url"`
|
Subscription string `json:"subscription_url"`
|
||||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||||
Remnawave remnawave.Settings `json:"remnawave"`
|
Remnawave remnawave.Settings `json:"remnawave"`
|
||||||
Hy2 core.Hy2Options `json:"hy2"`
|
Hy2 core.Hy2Options `json:"hy2"`
|
||||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||||
|
ProvisionReady bool `json:"provision_ready,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PingBestResult struct {
|
type PingBestResult struct {
|
||||||
@@ -117,26 +118,27 @@ func (a *App) GetState() (UIState, error) {
|
|||||||
corePath, coreReady = path, true
|
corePath, coreReady = path, true
|
||||||
}
|
}
|
||||||
out := UIState{
|
out := UIState{
|
||||||
Connected: st.Connected,
|
Connected: st.Connected,
|
||||||
Profile: st.Profile,
|
Profile: st.Profile,
|
||||||
ActiveProfile: active,
|
ActiveProfile: active,
|
||||||
Protocol: string(st.Protocol),
|
Protocol: string(st.Protocol),
|
||||||
HTTPProxy: st.HTTPProxy,
|
HTTPProxy: st.HTTPProxy,
|
||||||
SOCKSProxy: st.SOCKSProxy,
|
SOCKSProxy: st.SOCKSProxy,
|
||||||
SystemProxy: cfg.SystemProxy,
|
SystemProxy: cfg.SystemProxy,
|
||||||
Proxy: proxy,
|
Proxy: proxy,
|
||||||
CoreReady: coreReady,
|
CoreReady: coreReady,
|
||||||
CorePath: corePath,
|
CorePath: corePath,
|
||||||
ConfigPath: a.CfgPath,
|
ConfigPath: a.CfgPath,
|
||||||
Profiles: cfg.ListProfiles(),
|
Profiles: cfg.ListProfiles(),
|
||||||
Version: update.DisplayVersion(),
|
Version: update.DisplayVersion(),
|
||||||
Update: a.UpdateStatus,
|
Update: a.UpdateStatus,
|
||||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||||
Subscription: cfg.SubscriptionURL,
|
Subscription: cfg.SubscriptionURL,
|
||||||
SubInfo: a.Mgr.SubscriptionInfo(),
|
SubInfo: a.Mgr.SubscriptionInfo(),
|
||||||
Remnawave: a.Mgr.RemnawaveSettings(),
|
Remnawave: a.Mgr.RemnawaveSettings(),
|
||||||
Hy2: a.Mgr.ActiveHy2Options(),
|
Hy2: a.Mgr.ActiveHy2Options(),
|
||||||
StorePackaged: update.IsStorePackaged(),
|
StorePackaged: update.IsStorePackaged(),
|
||||||
|
ProvisionReady: a.Mgr.ProvisionConfigured(),
|
||||||
}
|
}
|
||||||
if out.Protocol == "" {
|
if out.Protocol == "" {
|
||||||
if p, err := cfg.ActiveProfile(); err == nil {
|
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)
|
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) {
|
func (a *App) PingServers() ([]netcheck.Result, error) {
|
||||||
return a.runPings()
|
return a.runPings()
|
||||||
}
|
}
|
||||||
@@ -511,6 +517,8 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|||||||
_ = json.Unmarshal(args[0], &s)
|
_ = json.Unmarshal(args[0], &s)
|
||||||
}
|
}
|
||||||
return a.ImportRemnawave(s)
|
return a.ImportRemnawave(s)
|
||||||
|
case "provisionUser":
|
||||||
|
return a.ProvisionUser(arg(args, 0, ""))
|
||||||
case "quit":
|
case "quit":
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|||||||
@@ -1026,6 +1026,15 @@
|
|||||||
<button class="action primary" id="rwSyncBtn" type="button" style="flex:1">Синхронизировать</button>
|
<button class="action primary" id="rwSyncBtn" type="button" style="flex:1">Синхронизировать</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -1158,7 +1167,7 @@
|
|||||||
const methods = [
|
const methods = [
|
||||||
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
|
||||||
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
|
||||||
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave","quit"
|
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave","provisionUser","quit"
|
||||||
];
|
];
|
||||||
if (typeof window.getState === "function") return;
|
if (typeof window.getState === "function") return;
|
||||||
window.__navisHttp = true;
|
window.__navisHttp = true;
|
||||||
@@ -1266,6 +1275,9 @@
|
|||||||
const rwCaddy = $("rwCaddy");
|
const rwCaddy = $("rwCaddy");
|
||||||
const rwSaveBtn = $("rwSaveBtn");
|
const rwSaveBtn = $("rwSaveBtn");
|
||||||
const rwSyncBtn = $("rwSyncBtn");
|
const rwSyncBtn = $("rwSyncBtn");
|
||||||
|
const provisionBox = $("provisionBox");
|
||||||
|
const provUser = $("provUser");
|
||||||
|
const provBtn = $("provBtn");
|
||||||
const hero = $("hero");
|
const hero = $("hero");
|
||||||
const protoChip = $("protoChip");
|
const protoChip = $("protoChip");
|
||||||
const heroHint = $("heroHint");
|
const heroHint = $("heroHint");
|
||||||
@@ -1618,6 +1630,9 @@
|
|||||||
if (rwCaddy) rwCaddy.disabled = busy;
|
if (rwCaddy) rwCaddy.disabled = busy;
|
||||||
if (rwSaveBtn) rwSaveBtn.disabled = busy;
|
if (rwSaveBtn) rwSaveBtn.disabled = busy;
|
||||||
if (rwSyncBtn) rwSyncBtn.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;
|
btn.disabled = busy;
|
||||||
|
|
||||||
rememberPings(state.pings || []);
|
rememberPings(state.pings || []);
|
||||||
@@ -1696,7 +1711,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paintButtonsLocked(locked) {
|
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;
|
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 () => {
|
pingBtn.addEventListener("click", () => withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
setMeta("Пинг серверов…");
|
setMeta("Пинг серверов…");
|
||||||
|
|||||||
@@ -221,6 +221,13 @@ func (m *Manager) SetSystemProxy(enabled bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) UpdateProxyURI(proxy string) error {
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
active, err := m.cfg.ActiveProfile()
|
active, err := m.cfg.ActiveProfile()
|
||||||
@@ -253,6 +260,11 @@ func (m *Manager) SetActiveProfile(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveProfile(name, proxy 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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.engine != nil && m.engine.Running() {
|
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.
|
// SaveActiveProfile updates the current profile (rename if needed) and proxy URI.
|
||||||
func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.engine != nil && m.engine.Running() {
|
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
|
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 {
|
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ func emojiToISO(flag string) string {
|
|||||||
cc := string([]byte{byte('A' + a), byte('A' + b)})
|
cc := string([]byte{byte('A' + a), byte('A' + b)})
|
||||||
if !Known(cc) {
|
if !Known(cc) {
|
||||||
// Still return regional indicator pair as ISO guess for display.
|
// 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 cc
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -118,6 +118,49 @@ var countryNames = []nameCode{
|
|||||||
{"ireland", "IE"}, {"iceland", "IS"}, {"luxembourg", "LU"},
|
{"ireland", "IE"}, {"iceland", "IS"}, {"luxembourg", "LU"},
|
||||||
{"malaysia", "MY"}, {"thailand", "TH"}, {"vietnam", "VN"},
|
{"malaysia", "MY"}, {"thailand", "TH"}, {"vietnam", "VN"},
|
||||||
{"indonesia", "ID"}, {"philippines", "PH"}, {"south africa", "ZA"},
|
{"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{}{
|
var isoSet = map[string]struct{}{
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ func TestFromText(t *testing.T) {
|
|||||||
{"🇺🇸 US West", "US"},
|
{"🇺🇸 US West", "US"},
|
||||||
{"Germany Hetzner", "DE"},
|
{"Germany Hetzner", "DE"},
|
||||||
{"unknown-node", ""},
|
{"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 {
|
for _, c := range cases {
|
||||||
cc, emoji := FromText(c.in)
|
cc, emoji := FromText(c.in)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package linknorm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"vpnclient/internal/config"
|
"vpnclient/internal/config"
|
||||||
@@ -11,6 +12,23 @@ import (
|
|||||||
"vpnclient/internal/protocols/xray"
|
"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).
|
// 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) {
|
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
|
||||||
raw = strings.TrimSpace(raw)
|
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)
|
u, rem, err := awg.NormalizeShareLink(raw)
|
||||||
return u, config.ProtocolAWG, rem, err
|
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) {
|
if xray.Detect(raw) {
|
||||||
u, rem, err := xray.NormalizeShareLink(raw)
|
u, rem, err := xray.NormalizeShareLink(raw)
|
||||||
return u, xray.DetectProtocol(raw), rem, err
|
return u, xray.DetectProtocol(raw), rem, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
@@ -22,7 +22,7 @@ const CurrentVersion = "3.9.0"
|
|||||||
|
|
||||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||||
const BuildNumber = 2
|
const BuildNumber = 3
|
||||||
|
|
||||||
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
||||||
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
const DefaultManifestURL = "https://git.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).
|
// Manifest describes a remote release (multi-platform + legacy single URL).
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
URL string `json:"url,omitempty"` // legacy windows-amd64
|
URL string `json:"url,omitempty"` // legacy windows-amd64
|
||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Mandatory bool `json:"mandatory,omitempty"`
|
Mandatory bool `json:"mandatory,omitempty"`
|
||||||
Platform string `json:"platform,omitempty"` // legacy
|
Platform string `json:"platform,omitempty"` // legacy
|
||||||
OS string `json:"os,omitempty"`
|
OS string `json:"os,omitempty"`
|
||||||
Arch string `json:"arch,omitempty"`
|
Arch string `json:"arch,omitempty"`
|
||||||
Platforms map[string]PlatformAsset `json:"platforms,omitempty"`
|
Platforms map[string]PlatformAsset `json:"platforms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status is returned to the UI / CLI.
|
// Status is returned to the UI / CLI.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="EvilFox.Navis"
|
Name="EvilFox.Navis"
|
||||||
Publisher="CN=EvilFox"
|
Publisher="CN=EvilFox"
|
||||||
Version="3.9.0.2"
|
Version="3.9.0.3"
|
||||||
ProcessorArchitecture="x64" />
|
ProcessorArchitecture="x64" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
param(
|
param(
|
||||||
[string]$ExePath = "",
|
[string]$ExePath = "",
|
||||||
[string]$OutDir = "",
|
[string]$OutDir = "",
|
||||||
[string]$Version = "3.9.0.2",
|
[string]$Version = "3.9.0.3",
|
||||||
[string]$Name = "EvilFox.Navis",
|
[string]$Name = "EvilFox.Navis",
|
||||||
[string]$Publisher = "CN=EvilFox",
|
[string]$Publisher = "CN=EvilFox",
|
||||||
[string]$DisplayName = "Navis",
|
[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=...?>.
|
# Prefer exact placeholder swaps so we never touch <?xml version=...?>.
|
||||||
$manifestText = $manifestText.Replace('Name="EvilFox.Navis"', "Name=`"$Name`"")
|
$manifestText = $manifestText.Replace('Name="EvilFox.Navis"', "Name=`"$Name`"")
|
||||||
$manifestText = $manifestText.Replace('Publisher="CN=EvilFox"', "Publisher=`"$Publisher`"")
|
$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('<DisplayName>Navis</DisplayName>', "<DisplayName>$DisplayName</DisplayName>")
|
||||||
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
$manifestText = $manifestText.Replace('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
||||||
$manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"")
|
$manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"")
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "3.9.0",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"mandatory": false,
|
"mandatory": false,
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
"sha256": "cddcfa9b2748ec951ec3265b99cdd79e5f0947370f88f28c10edf6ed28e3996d",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64"
|
"arch": "amd64"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": { "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": 2 },
|
"ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 3 },
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
"FileOS": "40004",
|
"FileOS": "40004",
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis 2 — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.9.0.2",
|
"FileVersion": "3.9.0.3",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.9.0.2",
|
"ProductVersion": "3.9.0.3",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user