Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:01:37 +03:00
co-authored by Cursor
parent 015ded9bd5
commit e34312ef9c
18 changed files with 818 additions and 174 deletions
+5 -3
View File
@@ -1,4 +1,4 @@
//go:build windows //go:build windows
package main package main
@@ -56,6 +56,7 @@ type uiState struct {
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"`
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"`
@@ -213,6 +214,7 @@ func (a *app) getState() (uiState, error) {
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(),
Remnawave: a.mgr.RemnawaveSettings(), Remnawave: a.mgr.RemnawaveSettings(),
Hy2: a.mgr.ActiveHy2Options(), Hy2: a.mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(), StorePackaged: update.IsStorePackaged(),
@@ -327,7 +329,7 @@ func (a *app) saveHy2(opts core.Hy2Options) error {
return a.mgr.SaveHy2Options(opts) return a.mgr.SaveHy2Options(opts)
} }
func (a *app) importSubscription(rawURL string) (int, error) { func (a *app) importSubscription(rawURL string) (core.ImportResult, error) {
return a.mgr.ImportSubscription(rawURL) return a.mgr.ImportSubscription(rawURL)
} }
@@ -335,7 +337,7 @@ func (a *app) saveRemnawave(s remnawave.Settings) error {
return a.mgr.SaveRemnawaveSettings(s) return a.mgr.SaveRemnawaveSettings(s)
} }
func (a *app) importRemnawave(s remnawave.Settings) (int, error) { func (a *app) importRemnawave(s remnawave.Settings) (core.ImportResult, error) {
return a.mgr.ImportRemnawave(s) return a.mgr.ImportRemnawave(s)
} }
+4 -4
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.9.0", "version": "3.9.0",
"notes": "3.9.0: Remnawave API config delivery, Remnawave-inspired UI + flags, Windows 3.9.0.1.", "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.",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.9.0", "version": "3.9.0",
"notes": "3.9.0: Remnawave API config delivery, Remnawave-inspired UI + flags, Windows 3.9.0.1.", "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.",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
+4 -2
View File
@@ -54,6 +54,7 @@ type UIState struct {
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"`
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"`
@@ -132,6 +133,7 @@ func (a *App) GetState() (UIState, error) {
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(),
Remnawave: a.Mgr.RemnawaveSettings(), Remnawave: a.Mgr.RemnawaveSettings(),
Hy2: a.Mgr.ActiveHy2Options(), Hy2: a.Mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(), StorePackaged: update.IsStorePackaged(),
@@ -239,7 +241,7 @@ func (a *App) SaveHy2(opts core.Hy2Options) error {
return a.Mgr.SaveHy2Options(opts) return a.Mgr.SaveHy2Options(opts)
} }
func (a *App) ImportSubscription(rawURL string) (int, error) { func (a *App) ImportSubscription(rawURL string) (core.ImportResult, error) {
return a.Mgr.ImportSubscription(rawURL) return a.Mgr.ImportSubscription(rawURL)
} }
@@ -247,7 +249,7 @@ func (a *App) SaveRemnawave(s remnawave.Settings) error {
return a.Mgr.SaveRemnawaveSettings(s) return a.Mgr.SaveRemnawaveSettings(s)
} }
func (a *App) ImportRemnawave(s remnawave.Settings) (int, error) { func (a *App) ImportRemnawave(s remnawave.Settings) (core.ImportResult, error) {
return a.Mgr.ImportRemnawave(s) return a.Mgr.ImportRemnawave(s)
} }
+120 -4
View File
@@ -507,6 +507,35 @@
font-weight: 600; font-weight: 600;
font-size: .9rem; font-size: .9rem;
} }
.subinfo {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
padding: 11px 13px;
margin-bottom: 12px;
display: grid;
gap: 7px;
}
.subinfo-title {
font-family: Outfit, sans-serif;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
}
.subinfo-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: .84rem;
}
.subinfo-row .k { color: var(--muted); flex: 0 0 auto; }
.subinfo-row .v { font-weight: 700; text-align: right; min-width: 0; }
.subinfo-row .v.warn { color: #dc2626; }
[data-theme="dark"] .subinfo-row .v.warn { color: #f87171; }
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; } .switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.slider { .slider {
@@ -1002,6 +1031,22 @@
</details> </details>
</section> </section>
<section class="subinfo" id="subInfoCard" hidden>
<div class="subinfo-title">Подписка</div>
<div class="subinfo-row" id="subExpireRow" hidden>
<span class="k">Окончание подписки</span>
<span class="v" id="subExpireVal"></span>
</div>
<div class="subinfo-row" id="subTrafficRow" hidden>
<span class="k">Трафик</span>
<span class="v" id="subTrafficVal"></span>
</div>
<div class="subinfo-row" id="subDevicesRow" hidden>
<span class="k">Устройства</span>
<span class="v" id="subDevicesVal"></span>
</div>
</section>
<div class="row"> <div class="row">
<span>Системный прокси</span> <span>Системный прокси</span>
<label class="switch"> <label class="switch">
@@ -1444,6 +1489,67 @@
}); });
} }
function fmtTraffic(bytes) {
if (!bytes || bytes <= 0) return "0 ГБ";
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) {
const tb = gb / 1024;
return (tb >= 100 ? Math.round(tb) : tb.toFixed(1)) + " ТБ";
}
if (gb >= 100) return Math.round(gb) + " ГБ";
if (gb >= 1) return gb.toFixed(1) + " ГБ";
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? Math.round(mb) : 1) + " МБ";
}
function renderSubInfo(info) {
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
const hasExpire = !!(info && info.expire);
const hasTraffic = !!(info && (info.total || info.download || info.upload));
const hasDevices = !!(info && info.devices_known);
if (!info || (!hasExpire && !hasTraffic && !hasDevices)) {
card.hidden = true;
return;
}
card.hidden = false;
expireRow.hidden = !hasExpire;
if (hasExpire) {
const d = new Date(info.expire * 1000);
let text = "";
try {
text = d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
} catch (_) {
text = d.toISOString().slice(0, 10);
}
const daysLeft = Math.floor((d.getTime() - Date.now()) / 86400000);
const v = $("subExpireVal");
if (daysLeft < 0) {
v.textContent = text + " · истекла";
v.classList.add("warn");
} else {
v.textContent = daysLeft <= 14 ? (text + " · осталось " + daysLeft + " дн.") : text;
v.classList.toggle("warn", daysLeft <= 3);
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? (fmtTraffic(used) + " из " + fmtTraffic(info.total))
: (fmtTraffic(used) + " · Безлимит");
}
devicesRow.hidden = !hasDevices;
if (hasDevices) {
const n = info.devices || 0;
$("subDevicesVal").textContent = info.device_limit > 0 ? (n + " из " + info.device_limit) : String(n);
}
}
function renderUpdate(u, version) { function renderUpdate(u, version) {
const label = "Navis v" + (version || "?"); const label = "Navis v" + (version || "?");
verLabel.textContent = label; verLabel.textContent = label;
@@ -1517,6 +1623,7 @@
rememberPings(state.pings || []); rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile); fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderUpdate(state.update, state.version); renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
if (typeof state.subscription_url === "string" && !dirty) { if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url; subUrl.value = state.subscription_url;
} }
@@ -1699,13 +1806,22 @@
return; return;
} }
setMeta("Загрузка подписки…"); setMeta("Загрузка подписки…");
const n = await importSubscription(url); const r = await importSubscription(url);
formHydrated = false; formHydrated = false;
dirty = false; dirty = false;
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok"); setMeta(importSummary(r) + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked); await runBest(!!autoBest.checked);
} }
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Импортировано " + (r.imported || 0) + " серверов";
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Импортировано: " + r;
}
function readRemnawave() { function readRemnawave() {
return { return {
base_url: (rwBase && rwBase.value || "").trim(), base_url: (rwBase && rwBase.value || "").trim(),
@@ -1744,10 +1860,10 @@
return; return;
} }
setMeta("Remnawave: загрузка конфигов…"); setMeta("Remnawave: загрузка конфигов…");
const n = await importRemnawave(s); const r = await importRemnawave(s);
formHydrated = false; formHydrated = false;
dirty = false; dirty = false;
setMeta("Remnawave: импортировано " + n + " · пинг…", "ok"); setMeta("Remnawave: " + importSummary(r).toLowerCase() + " · пинг…", "ok");
await runBest(!!autoBest.checked); await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); } } catch (e) { setMeta(String(e), "err"); }
})); }));
+20
View File
@@ -39,9 +39,29 @@ type Config struct {
RemnawaveShortUUID string `json:"remnawave_short_uuid,omitempty"` RemnawaveShortUUID string `json:"remnawave_short_uuid,omitempty"`
RemnawaveCaddyToken string `json:"remnawave_caddy_token,omitempty"` RemnawaveCaddyToken string `json:"remnawave_caddy_token,omitempty"`
// SubInfo caches last-known subscription usage so the UI can render it
// after restart; refreshed on subscription import.
SubInfo *SubscriptionInfo `json:"subscription_info,omitempty"`
Profiles []Profile `json:"profiles"` Profiles []Profile `json:"profiles"`
} }
// SubscriptionInfo is last-known subscription usage metadata.
// Traffic values are bytes; Total==0 means unlimited/unknown.
type SubscriptionInfo struct {
Upload int64 `json:"upload,omitempty"`
Download int64 `json:"download,omitempty"`
Total int64 `json:"total,omitempty"`
Expire int64 `json:"expire,omitempty"` // unix seconds; 0 = unknown
// Devices are known only when the Remnawave API token is configured.
DevicesKnown bool `json:"devices_known,omitempty"`
Devices int `json:"devices,omitempty"`
DeviceLimit int `json:"device_limit,omitempty"` // 0 = no limit / unknown
UpdatedAt int64 `json:"updated_at,omitempty"` // unix seconds of last refresh
}
// Profile describes one connection endpoint. // Profile describes one connection endpoint.
type Profile struct { type Profile struct {
Name string `json:"name"` Name string `json:"name"`
+89 -17
View File
@@ -439,7 +439,14 @@ func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
return config.Save(m.cfgPath, *m.cfg) return config.Save(m.cfgPath, *m.cfg)
} }
func (m *Manager) ImportSubscription(rawURL string) (int, error) { // ImportResult reports how a subscription import went.
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Warnings []string `json:"warnings,omitempty"`
}
func (m *Manager) ImportSubscription(rawURL string) (ImportResult, error) {
rawURL = strings.TrimSpace(rawURL) rawURL = strings.TrimSpace(rawURL)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel() defer cancel()
@@ -452,22 +459,22 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
Token: m.RemnawaveSettings().Token, Token: m.RemnawaveSettings().Token,
CaddyToken: m.RemnawaveSettings().CaddyToken, CaddyToken: m.RemnawaveSettings().CaddyToken,
} }
items, used, err := remnawave.Fetch(ctx, s) res, used, err := remnawave.Fetch(ctx, s)
if err != nil { if err != nil {
return 0, err return ImportResult{}, err
} }
return m.applySubscriptionItems(items, used, &s) return m.applySubscription(ctx, res, used, &s)
} }
items, err := subscription.Fetch(ctx, rawURL) res, err := subscription.Fetch(ctx, rawURL)
if err != nil { if err != nil {
return 0, err return ImportResult{}, err
} }
return m.applySubscriptionItems(items, rawURL, nil) return m.applySubscription(ctx, res, rawURL, nil)
} }
// ImportRemnawave fetches configs using saved / provided panel settings. // ImportRemnawave fetches configs using saved / provided panel settings.
func (m *Manager) ImportRemnawave(s remnawave.Settings) (int, error) { func (m *Manager) ImportRemnawave(s remnawave.Settings) (ImportResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel() defer cancel()
if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.ShortUUID) == "" { if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.ShortUUID) == "" {
@@ -485,16 +492,55 @@ func (m *Manager) ImportRemnawave(s remnawave.Settings) (int, error) {
s.CaddyToken = cur.CaddyToken s.CaddyToken = cur.CaddyToken
} }
} }
items, used, err := remnawave.Fetch(ctx, s) res, used, err := remnawave.Fetch(ctx, s)
if err != nil { if err != nil {
return 0, err return ImportResult{}, err
} }
return m.applySubscriptionItems(items, used, &s) return m.applySubscription(ctx, res, used, &s)
} }
func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL string, rw *remnawave.Settings) (int, error) { func (m *Manager) applySubscription(ctx context.Context, res *subscription.Result, usedURL string, rw *remnawave.Settings) (ImportResult, error) {
if len(items) == 0 { if res == nil || len(res.Items) == 0 {
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)") if res != nil && res.Skipped > 0 {
return ImportResult{Skipped: res.Skipped, Warnings: res.Warnings},
fmt.Errorf("нет валидных серверов: %d записей пропущено (%s)", res.Skipped, strings.Join(res.Warnings, "; "))
}
return ImportResult{}, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
}
// Subscription usage info: header data first, enriched via Remnawave API
// (devices / limits) when an API token is configured. Best-effort.
subInfo := buildSubInfo(res.Info)
{
s := m.RemnawaveSettings()
if rw != nil {
s = *rw
if strings.TrimSpace(s.Token) == "" {
s.Token = m.RemnawaveSettings().Token
}
}
if strings.TrimSpace(s.Token) != "" && strings.TrimSpace(s.BaseURL) != "" && strings.TrimSpace(s.ShortUUID) != "" {
if ui, err := remnawave.FetchUserInfo(ctx, s); err == nil && ui != nil {
if subInfo == nil {
subInfo = &config.SubscriptionInfo{}
}
if subInfo.Upload == 0 && subInfo.Download == 0 && ui.UsedTraffic > 0 {
subInfo.Download = ui.UsedTraffic
}
if subInfo.Total == 0 && ui.TrafficLimit > 0 {
subInfo.Total = ui.TrafficLimit
}
if subInfo.Expire == 0 && ui.ExpireAt > 0 {
subInfo.Expire = ui.ExpireAt
}
subInfo.DevicesKnown = ui.DevicesKnown
subInfo.Devices = ui.Devices
subInfo.DeviceLimit = ui.DeviceLimit
}
}
}
if subInfo != nil {
subInfo.UpdatedAt = time.Now().Unix()
} }
m.mu.Lock() m.mu.Lock()
@@ -512,7 +558,10 @@ func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL stri
m.cfg.RemnawaveCaddyToken = t m.cfg.RemnawaveCaddyToken = t
} }
} }
for _, it := range items { if subInfo != nil {
m.cfg.SubInfo = subInfo
}
for _, it := range res.Items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol) _ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
for i := range m.cfg.Profiles { for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name { if m.cfg.Profiles[i].Name == it.Name {
@@ -522,9 +571,32 @@ func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL stri
} }
} }
if err := config.Save(m.cfgPath, *m.cfg); err != nil { if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err return ImportResult{}, err
} }
return len(items), nil return ImportResult{Imported: len(res.Items), Skipped: res.Skipped, Warnings: res.Warnings}, nil
}
func buildSubInfo(info *subscription.Info) *config.SubscriptionInfo {
if info == nil {
return nil
}
return &config.SubscriptionInfo{
Upload: info.Upload,
Download: info.Download,
Total: info.Total,
Expire: info.Expire,
}
}
// SubscriptionInfo returns the cached subscription usage info (may be nil).
func (m *Manager) SubscriptionInfo() *config.SubscriptionInfo {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg.SubInfo == nil {
return nil
}
cp := *m.cfg.SubInfo
return &cp
} }
func (m *Manager) SaveConfig() error { func (m *Manager) SaveConfig() error {
+31 -27
View File
@@ -87,7 +87,7 @@ func ParseInput(raw string) (base, shortUUID string, ok bool) {
// Fetch downloads configs via Remnawave public sub URL and, if a token is set, // Fetch downloads configs via Remnawave public sub URL and, if a token is set,
// authenticated subscription/user endpoints. Parsed items reuse subscription.ParseBody. // authenticated subscription/user endpoints. Parsed items reuse subscription.ParseBody.
func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error) { func Fetch(ctx context.Context, s Settings) (*subscription.Result, string, error) {
s.BaseURL = NormalizeBase(s.BaseURL) s.BaseURL = NormalizeBase(s.BaseURL)
s.ShortUUID = strings.TrimSpace(s.ShortUUID) s.ShortUUID = strings.TrimSpace(s.ShortUUID)
s.Token = strings.TrimSpace(s.Token) s.Token = strings.TrimSpace(s.Token)
@@ -105,19 +105,19 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
var lastErr error var lastErr error
// 1) Public subscription (usual end-user path — no token). // 1) Public subscription (usual end-user path — no token).
if items, err := fetchAndParse(ctx, client, s, pub, false); err == nil && len(items) > 0 { if res, err := fetchAndParse(ctx, client, s, pub, false); err == nil && res != nil && len(res.Items) > 0 {
return items, pub, nil return res, pub, nil
} else if err != nil { } else if err != nil {
lastErr = err lastErr = err
} }
// 2) With API token: resolve subscriptionUrl / raw payload. // 2) With API token: resolve subscriptionUrl / raw payload.
if s.Token != "" { if s.Token != "" {
if items, used, err := fetchWithToken(ctx, client, s); err == nil && len(items) > 0 { if res, used, err := fetchWithToken(ctx, client, s); err == nil && res != nil && len(res.Items) > 0 {
if used == "" { if used == "" {
used = pub used = pub
} }
return items, used, nil return res, used, nil
} else if err != nil { } else if err != nil {
lastErr = err lastErr = err
} }
@@ -129,7 +129,7 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа подписки)") return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа подписки)")
} }
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]subscription.Item, string, error) { func fetchWithToken(ctx context.Context, client *http.Client, s Settings) (*subscription.Result, string, error) {
base := s.BaseURL base := s.BaseURL
short := s.ShortUUID short := s.ShortUUID
@@ -142,18 +142,18 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
} }
var lastErr error var lastErr error
for _, endpoint := range candidates { for _, endpoint := range candidates {
body, err := doGET(ctx, client, s, endpoint, true) body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil { if err != nil {
lastErr = err lastErr = err
continue continue
} }
if items, err := parseRemnawaveBody(body); err == nil && len(items) > 0 { if res, err := parseRemnawaveBody(body); err == nil && res != nil && len(res.Items) > 0 {
return items, endpoint, nil return res, endpoint, nil
} }
if subURL := extractSubscriptionURL(body); subURL != "" { if subURL := extractSubscriptionURL(body); subURL != "" {
items, err := subscription.Fetch(ctx, subURL) res, err := subscription.Fetch(ctx, subURL)
if err == nil && len(items) > 0 { if err == nil && res != nil && len(res.Items) > 0 {
return items, subURL, nil return res, subURL, nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
@@ -166,18 +166,22 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги") return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги")
} }
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) ([]subscription.Item, error) { func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (*subscription.Result, error) {
body, err := doGET(ctx, client, s, endpoint, auth) body, header, err := doGET(ctx, client, s, endpoint, auth)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return parseRemnawaveBody(body) res, err := parseRemnawaveBody(body)
if res != nil && res.Info == nil {
res.Info = subscription.ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
}
return res, err
} }
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, error) { func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return "", err return "", nil, err
} }
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)") req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)")
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
@@ -196,21 +200,21 @@ func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("Remnawave запрос: %w", err) return "", nil, fmt.Errorf("Remnawave запрос: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil { if err != nil {
return "", err return "", nil, err
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := strings.TrimSpace(string(raw)) msg := strings.TrimSpace(string(raw))
if len(msg) > 180 { if len(msg) > 180 {
msg = msg[:180] + "…" msg = msg[:180] + "…"
} }
return "", fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg) return "", nil, fmt.Errorf("Remnawave HTTP %d: %s", resp.StatusCode, msg)
} }
return string(raw), nil return string(raw), resp.Header, nil
} }
func shouldSendProxyHeaders(u *url.URL) bool { func shouldSendProxyHeaders(u *url.URL) bool {
@@ -228,24 +232,24 @@ func shouldSendProxyHeaders(u *url.URL) bool {
return ip != nil && (ip.IsLoopback() || ip.IsPrivate()) return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
} }
func parseRemnawaveBody(body string) ([]subscription.Item, error) { func parseRemnawaveBody(body string) (*subscription.Result, error) {
body = strings.TrimSpace(body) body = strings.TrimSpace(body)
if body == "" { if body == "" {
return nil, fmt.Errorf("пустой ответ Remnawave") return nil, fmt.Errorf("пустой ответ Remnawave")
} }
// Try JSON envelope / raw DTO first, then classic subscription parse. // Try JSON envelope / raw DTO first, then classic subscription parse.
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") { if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
if items, err := subscription.ParseBody(flattenJSONLinks(body)); err == nil && len(items) > 0 { if res, err := subscription.ParseBodyDetailed(flattenJSONLinks(body)); err == nil && len(res.Items) > 0 {
return items, nil return res, nil
} }
// Also try parsing the whole JSON as if links were string values only. // Also try parsing the whole JSON as if links were string values only.
if flat := extractLinkStrings(body); flat != "" { if flat := extractLinkStrings(body); flat != "" {
if items, err := subscription.ParseBody(flat); err == nil && len(items) > 0 { if res, err := subscription.ParseBodyDetailed(flat); err == nil && len(res.Items) > 0 {
return items, nil return res, nil
} }
} }
} }
return subscription.ParseBody(body) return subscription.ParseBodyDetailed(body)
} }
func flattenJSONLinks(body string) string { func flattenJSONLinks(body string) string {
+122
View File
@@ -0,0 +1,122 @@
package remnawave
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// UserInfo is subscription usage metadata from the Remnawave admin API
// (GET /api/users/by-short-uuid/{short} + GET /api/hwid/devices/{uuid}).
type UserInfo struct {
UsedTraffic int64 // bytes
TrafficLimit int64 // bytes; 0 = unlimited
ExpireAt int64 // unix seconds; 0 = unknown
DeviceLimit int // 0 = no limit / unknown
Devices int
DevicesKnown bool
}
// FetchUserInfo loads traffic/expiry and HWID device info via the admin API.
// Requires BaseURL, ShortUUID and Token; degrade gracefully — the caller
// should treat any error as "info unavailable".
func FetchUserInfo(ctx context.Context, s Settings) (*UserInfo, error) {
s.BaseURL = NormalizeBase(s.BaseURL)
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
s.Token = strings.TrimSpace(s.Token)
if s.BaseURL == "" || s.ShortUUID == "" {
return nil, fmt.Errorf("нет URL панели / short UUID")
}
if s.Token == "" {
return nil, fmt.Errorf("нет API токена Remnawave")
}
client := &http.Client{Timeout: 20 * time.Second}
endpoint := s.BaseURL + "/api/users/by-short-uuid/" + url.PathEscape(s.ShortUUID)
body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil {
return nil, err
}
var payload struct {
Response userDTO `json:"response"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
// Some deployments return the DTO without the {"response": ...} envelope.
var direct userDTO
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil {
return nil, fmt.Errorf("Remnawave user info: %w", err)
}
payload.Response = direct
}
u := payload.Response
if u.UUID == "" && u.UsedTrafficBytes == 0 && u.ExpireAt == "" {
return nil, fmt.Errorf("Remnawave: пустой ответ user info")
}
info := &UserInfo{
UsedTraffic: u.UsedTrafficBytes,
TrafficLimit: u.TrafficLimitBytes,
DeviceLimit: u.HwidDeviceLimit,
}
if u.ExpireAt != "" {
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
info.ExpireAt = t.Unix()
}
}
if u.UUID != "" {
if total, ok := fetchDeviceCount(ctx, client, s, u.UUID); ok {
info.Devices = total
info.DevicesKnown = true
}
}
return info, nil
}
type userDTO struct {
UUID string `json:"uuid"`
UsedTrafficBytes int64 `json:"usedTrafficBytes"`
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
ExpireAt string `json:"expireAt"`
HwidDeviceLimit int `json:"hwidDeviceLimit"`
}
// fetchDeviceCount returns the number of HWID devices attached to a user.
// Handles both response shapes: {"response":{"total":N,"devices":[...]}} and
// the legacy {"response":[...]}.
func fetchDeviceCount(ctx context.Context, client *http.Client, s Settings, userUUID string) (int, bool) {
endpoint := s.BaseURL + "/api/hwid/devices/" + url.PathEscape(userUUID)
body, _, err := doGET(ctx, client, s, endpoint, true)
if err != nil {
return 0, false
}
var wrapped struct {
Response json.RawMessage `json:"response"`
}
raw := json.RawMessage(body)
if err := json.Unmarshal([]byte(body), &wrapped); err == nil && len(wrapped.Response) > 0 {
raw = wrapped.Response
}
var obj struct {
Total *int `json:"total"`
Devices []json.RawMessage `json:"devices"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
if obj.Total != nil {
return *obj.Total, true
}
if obj.Devices != nil {
return len(obj.Devices), true
}
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
return len(arr), true
}
return 0, false
}
+215 -44
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -25,13 +26,45 @@ type Item struct {
URI string URI string
} }
// Info is subscription usage metadata from the subscription-userinfo header
// (upload/download/total in bytes, expire as unix seconds; 0 = unknown).
type Info struct {
Upload int64 `json:"upload,omitempty"`
Download int64 `json:"download,omitempty"`
Total int64 `json:"total,omitempty"`
Expire int64 `json:"expire,omitempty"`
}
// Result is a parsed subscription: imported items plus per-line skip diagnostics.
type Result struct {
Items []Item
Skipped int
Warnings []string
Info *Info
}
const maxWarnings = 8
var ( var (
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`) reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`) reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
// reURILine matches a standalone "scheme://..." line — only such lines are
// counted as skipped entries (YAML/HTML noise is ignored silently).
reURILine = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://\S`)
) )
// userAgents to try in order. Remnawave and most panels return raw share links
// for a generic UA, but return a Clash YAML config for Clash-like UAs — that
// YAML loses vless/trojan entries for us, so try the plain UA first.
var userAgents = []string{
"Navis/3.9 (+https://evilfox.win)",
"ClashMeta/1.18.0",
}
// Fetch downloads a subscription body and parses proxy share links. // Fetch downloads a subscription body and parses proxy share links.
func Fetch(ctx context.Context, rawURL string) ([]Item, error) { // Parsing is tolerant: unsupported or broken entries are skipped (with
// warnings), valid ones are imported.
func Fetch(ctx context.Context, rawURL string) (*Result, error) {
rawURL = strings.TrimSpace(rawURL) rawURL = strings.TrimSpace(rawURL)
if rawURL == "" { if rawURL == "" {
return nil, fmt.Errorf("пустой URL подписки") return nil, fmt.Errorf("пустой URL подписки")
@@ -41,37 +74,116 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
return nil, fmt.Errorf("нужен http(s) URL подписки") return nil, fmt.Errorf("нужен http(s) URL подписки")
} }
var (
best *Result
info *Info
lastErr error
)
for _, ua := range userAgents {
body, header, err := download(ctx, rawURL, ua)
if err != nil {
lastErr = err
continue
}
if info == nil {
info = ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
}
res, err := ParseBodyDetailed(body)
if res != nil && (best == nil || len(res.Items) > 0 || res.Skipped > best.Skipped) {
best = res
}
if err != nil {
lastErr = err
continue
}
if res != nil && len(res.Items) > 0 {
break
}
}
if best == nil || len(best.Items) == 0 {
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
}
best.Info = info
return best, nil
}
func download(ctx context.Context, rawURL, userAgent string) (string, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil { if err != nil {
return nil, err return "", nil, err
} }
req.Header.Set("User-Agent", "ClashMeta/1.18.0") req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
client := &http.Client{Timeout: 45 * time.Second} client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("не удалось скачать подписку: %w", err) return "", nil, fmt.Errorf("не удалось скачать подписку: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode) return "", nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
} }
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil { if err != nil {
return nil, err return "", nil, err
} }
items, err := ParseBody(string(body)) return string(body), resp.Header, nil
}
// ParseUserInfoHeader parses "upload=..; download=..; total=..; expire=<unix>".
// Returns nil when the header is absent or has no recognized fields.
func ParseUserInfoHeader(v string) *Info {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
info := &Info{}
found := false
for _, part := range strings.Split(v, ";") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
if err != nil { if err != nil {
return nil, err continue
} }
return items, nil switch strings.ToLower(strings.TrimSpace(kv[0])) {
case "upload":
info.Upload, found = n, true
case "download":
info.Download, found = n, true
case "total":
info.Total, found = n, true
case "expire":
info.Expire, found = n, true
}
}
if !found {
return nil
}
return info
} }
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links. // ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
func ParseBody(body string) ([]Item, error) { func ParseBody(body string) ([]Item, error) {
res, err := ParseBodyDetailed(body)
if err != nil {
return nil, err
}
return res.Items, nil
}
// ParseBodyDetailed parses a subscription body and reports skipped entries.
// The returned Result is non-nil even on error (zero valid entries) so callers
// can surface the collected warnings.
func ParseBodyDetailed(body string) (*Result, error) {
body = strings.TrimSpace(body) body = strings.TrimSpace(body)
if body == "" { if body == "" {
return nil, fmt.Errorf("пустая подписка") return &Result{}, fmt.Errorf("пустая подписка")
} }
candidates := []string{body} candidates := []string{body}
@@ -83,56 +195,68 @@ func ParseBody(body string) ([]Item, error) {
} }
} }
var lastErr error var best *Result
for _, text := range candidates { for _, text := range candidates {
items, err := parseText(text) res := parseText(text)
if err == nil && len(items) > 0 { if len(res.Items) > 0 {
return items, nil return res, nil
} }
if err != nil { if best == nil || res.Skipped > best.Skipped {
lastErr = err best = res
} }
} }
if lastErr != nil { if best == nil {
return nil, lastErr best = &Result{}
} }
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)") if best.Skipped > 0 {
return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)",
best.Skipped, strings.Join(best.Warnings, "; "))
}
return best, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
} }
func parseText(text string) ([]Item, error) { func parseText(text string) *Result {
lines := splitLines(text) res := &Result{}
// Also extract share URIs embedded in YAML/HTML/JSON.
extracted := reShareLine.FindAllString(text, -1)
for _, m := range extracted {
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
}
for _, block := range extractClashHY2(text) {
lines = append(lines, block)
}
out := make([]Item, 0, len(lines))
seen := map[string]int{} seen := map[string]int{}
seenURI := map[string]struct{}{} seenURI := map[string]struct{}{}
for _, line := range lines {
// countSkips is true only for raw subscription lines; URIs extracted from
// YAML/HTML/JSON noise are best-effort and never counted as "skipped".
process := func(line string, countSkips bool) {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue return
} }
line = strings.Trim(line, `"'`) line = strings.Trim(line, `"'`)
looksURI := reURILine.MatchString(line)
skip := func(reason string) {
if !countSkips || !looksURI {
return
}
res.Skipped++
if len(res.Warnings) < maxWarnings {
res.Warnings = append(res.Warnings, shortenLink(line)+" — "+reason)
}
}
proto := config.DetectProtocol(line) proto := config.DetectProtocol(line)
if proto == "" { if proto == "" {
continue skip("неподдерживаемая схема")
return
} }
// Skip bare https:// without credentials (common noise in HTML/YAML). // Bare https:// without credentials (common noise in HTML/YAML,
// or a subscription URL pasted among the links) is not a proxy link.
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") { if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
continue skip("нет логина и пароля в ссылке")
return
} }
normalized, detected, remark, err := linknorm.Normalize(proto, line) normalized, detected, remark, err := linknorm.Normalize(proto, line)
if err != nil { if err != nil {
continue skip(err.Error())
return
} }
if _, dup := seenURI[normalized]; dup { if _, dup := seenURI[normalized]; dup {
continue return
} }
seenURI[normalized] = struct{}{} seenURI[normalized] = struct{}{}
name := remark name := remark
@@ -147,12 +271,33 @@ func parseText(text string) ([]Item, error) {
name = fmt.Sprintf("%s-%d", base, n) name = fmt.Sprintf("%s-%d", base, n)
} }
seen[name] = 1 seen[name] = 1
out = append(out, Item{Name: name, Protocol: detected, URI: normalized}) res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized})
} }
if len(out) == 0 {
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)") for _, line := range splitLines(text) {
process(line, true)
} }
return out, nil // Also extract share URIs embedded in YAML/HTML/JSON.
for _, m := range reShareLine.FindAllString(text, -1) {
process(strings.TrimRight(m, ".,;)]}\"'"), false)
}
for _, block := range extractClashHY2(text) {
process(block, false)
}
return res
}
// shortenLink trims a link for warning messages, hiding query/credentials noise.
func shortenLink(line string) string {
if i := strings.IndexAny(line, "?#"); i >= 0 {
line = line[:i]
}
const max = 48
runes := []rune(line)
if len(runes) > max {
return string(runes[:max]) + "…"
}
return line
} }
func naiveLinkHasAuth(line string) bool { func naiveLinkHasAuth(line string) bool {
@@ -175,7 +320,7 @@ func extractClashHY2(text string) []string {
} }
var out []string var out []string
// Split roughly by list items. // Split roughly by list items.
chunks := strings.Split(text, "\n- ") chunks := splitClashItems(text)
for _, chunk := range chunks { for _, chunk := range chunks {
cl := strings.ToLower(chunk) cl := strings.ToLower(chunk)
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") { if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
@@ -223,8 +368,34 @@ func extractClashHY2(text string) []string {
return out return out
} }
// reClashItemStart matches a mapping list item ("- name: X", " - type: y"),
// but not nested scalar list entries like "- h3" under alpn.
var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`)
// splitClashItems splits a Clash YAML proxies list into per-proxy chunks,
// tolerating any list-item indentation ("- name:" at column 0 or nested).
func splitClashItems(text string) []string {
lines := splitLines(text)
var out []string
var cur []string
flush := func() {
if len(cur) > 0 {
out = append(out, strings.Join(cur, "\n"))
cur = nil
}
}
for _, line := range lines {
if reClashItemStart.MatchString(line) {
flush()
}
cur = append(cur, line)
}
flush()
return out
}
func yamlField(block, key string) string { func yamlField(block, key string) string {
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`) re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
m := re.FindStringSubmatch(block) m := re.FindStringSubmatch(block)
if len(m) < 2 { if len(m) < 2 {
return "" return ""
+135
View File
@@ -1,7 +1,10 @@
package subscription package subscription
import ( import (
"context"
"encoding/base64" "encoding/base64"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
) )
@@ -32,6 +35,138 @@ func TestParseBodyBase64(t *testing.T) {
} }
} }
// TestParseBodyTolerant: one broken entry must never abort the import —
// valid links are kept, unsupported/broken lines are skipped with warnings.
func TestParseBodyTolerant(t *testing.T) {
body := `vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ
hysteria2://secret@de.example.com:443/?sni=de.example.com#DE-HY2
https://api.example.com/ZKszcVcC3xbWb8qj
foobar://what@is.this:1
# comment line
`
res, err := ParseBodyDetailed(body)
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 2 {
t.Fatalf("imported %d items, want 2: %+v", len(res.Items), res.Items)
}
if res.Items[0].Protocol != "vless" || res.Items[1].Protocol != "hysteria2" {
t.Fatalf("protocols: %s, %s", res.Items[0].Protocol, res.Items[1].Protocol)
}
// https:// without userinfo + unknown scheme → skipped; empty/comment lines are not counted.
if res.Skipped != 2 {
t.Fatalf("skipped %d, want 2 (%v)", res.Skipped, res.Warnings)
}
if len(res.Warnings) != 2 {
t.Fatalf("warnings: %v", res.Warnings)
}
}
func TestParseBodyZeroValidListsReasons(t *testing.T) {
body := "https://api.example.com/sub-token-line\nfoobar://x@y:1\n"
res, err := ParseBodyDetailed(body)
if err == nil {
t.Fatal("want error for zero valid entries")
}
if res.Skipped != 2 {
t.Fatalf("skipped %d, want 2", res.Skipped)
}
if !strings.Contains(err.Error(), "2 записей пропущено") {
t.Fatalf("error should list skip count: %v", err)
}
}
func TestParseUserInfoHeader(t *testing.T) {
info := ParseUserInfoHeader("upload=0; download=5679561725; total=53687091200000; expire=1799366585")
if info == nil {
t.Fatal("nil info")
}
if info.Download != 5679561725 || info.Total != 53687091200000 || info.Expire != 1799366585 || info.Upload != 0 {
t.Fatalf("bad parse: %+v", info)
}
if ParseUserInfoHeader("") != nil {
t.Fatal("empty header must give nil")
}
if ParseUserInfoHeader("garbage") != nil {
t.Fatal("garbage header must give nil")
}
}
// Remnawave returns Clash YAML for Clash-like UAs and raw base64 links otherwise.
// Fetch must obtain the full server list (plain UA) and the userinfo header.
func TestFetchPrefersPlainUA(t *testing.T) {
links := "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ\n" +
"trojan://pass@no.example.com:20000?security=tls&type=ws#NO\n" +
"hysteria2://secret@de.example.com:443/?sni=de.example.com#DE\n" +
"ss://YWVzLTI1Ni1nY206cGFzcw@ss.example.com:8388#SS\n"
clashYAML := "proxies:\n - name: only-hy2\n type: hysteria2\n server: de.example.com\n port: 443\n password: secret\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=100; expire=1799366585")
if strings.Contains(strings.ToLower(r.Header.Get("User-Agent")), "clash") {
_, _ = w.Write([]byte(clashYAML))
return
}
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString([]byte(links))))
}))
defer srv.Close()
res, err := Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 3 {
t.Fatalf("imported %d, want 3 (vless+trojan+hy2): %+v", len(res.Items), res.Items)
}
if res.Skipped != 1 {
t.Fatalf("skipped %d, want 1 (ss://)", res.Skipped)
}
if res.Info == nil || res.Info.Expire != 1799366585 || res.Info.Total != 100 {
t.Fatalf("info: %+v", res.Info)
}
}
// Indented Clash proxies with nested lists (alpn) must split into separate blocks.
func TestParseBodyClashYAMLIndented(t *testing.T) {
body := `proxies:
- name: DE HY2
type: hysteria2
server: de.example.com
port: 443
udp: true
password: secret1
sni: de.example.com
alpn:
- h3
- name: NO HY2
type: hysteria2
server: no.example.com
port: 9000
password: secret2
alpn:
- h3
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items, want 2: %+v", len(items), items)
}
for i, host := range []string{"de.example.com:443", "no.example.com:9000"} {
if !strings.Contains(items[i].URI, host) {
t.Fatalf("item %d uri %s missing %s", i, items[i].URI, host)
}
}
for i, pass := range []string{"secret1", "secret2"} {
if !strings.Contains(items[i].URI, pass) {
t.Fatalf("item %d uri %s missing password %s", i, items[i].URI, pass)
}
}
}
func TestParseBodyClashYAML(t *testing.T) { func TestParseBodyClashYAML(t *testing.T) {
body := ` body := `
proxies: proxies:
+1 -1
View File
@@ -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 = 1 const BuildNumber = 2
// 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"
+1 -1
View File
@@ -33,7 +33,7 @@
<Identity <Identity
Name="EvilFox.Navis" Name="EvilFox.Navis"
Publisher="CN=EvilFox" Publisher="CN=EvilFox"
Version="3.9.0.1" Version="3.9.0.2"
ProcessorArchitecture="x64" /> ProcessorArchitecture="x64" />
<Properties> <Properties>
+2 -2
View File
@@ -15,7 +15,7 @@
param( param(
[string]$ExePath = "", [string]$ExePath = "",
[string]$OutDir = "", [string]$OutDir = "",
[string]$Version = "3.9.0.1", [string]$Version = "3.9.0.2",
[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.1"', "Version=`"$Version`"") $manifestText = $manifestText.Replace('Version="3.9.0.2"', "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`"")
+4 -4
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.9.0", "version": "3.9.0",
"notes": "3.9.0: Remnawave API config delivery, Remnawave-inspired UI + flags, Windows 3.9.0.1.", "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.",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"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": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614", "sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 1 }, "FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 2 },
"ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 1 }, "ProductVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 2 },
"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.1", "FileVersion": "3.9.0.2",
"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.1", "ProductVersion": "3.9.0.2",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/" "Comments": "Open-source VPN/proxy client. https://evilfox.win/"
}, },
"VarFileInfo": { "VarFileInfo": {