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/**/
|
||||
configs/config.json
|
||||
configs/remnawave-api.json
|
||||
dist/navis-release/windows/configs/
|
||||
configs/.navis-webview/
|
||||
cmd/vpnapp/resource.syso
|
||||
*.syso
|
||||
|
||||
@@ -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
@@ -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"/>
|
||||
|
||||
@@ -60,6 +60,7 @@ type uiState struct {
|
||||
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()
|
||||
|
||||
@@ -218,6 +220,7 @@ func (a *app) getState() (uiState, error) {
|
||||
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"`
|
||||
|
||||
@@ -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",
|
||||
"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"
|
||||
},
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ type UIState struct {
|
||||
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 {
|
||||
@@ -137,6 +138,7 @@ func (a *App) GetState() (UIState, error) {
|
||||
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)
|
||||
|
||||
@@ -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("Пинг серверов…");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)})
|
||||
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{}{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<Identity
|
||||
Name="EvilFox.Navis"
|
||||
Publisher="CN=EvilFox"
|
||||
Version="3.9.0.2"
|
||||
Version="3.9.0.3"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user