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:
+24
-22
@@ -1,4 +1,4 @@
|
||||
//go:build windows
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
@@ -40,25 +40,26 @@ type app struct {
|
||||
}
|
||||
|
||||
type uiState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -213,6 +214,7 @@ func (a *app) getState() (uiState, error) {
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
SubInfo: a.mgr.SubscriptionInfo(),
|
||||
Remnawave: a.mgr.RemnawaveSettings(),
|
||||
Hy2: a.mgr.ActiveHy2Options(),
|
||||
StorePackaged: update.IsStorePackaged(),
|
||||
@@ -327,7 +329,7 @@ func (a *app) saveHy2(opts core.Hy2Options) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -335,7 +337,7 @@ func (a *app) saveRemnawave(s remnawave.Settings) error {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
||||
{
|
||||
{
|
||||
"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",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+4
-4
@@ -1,16 +1,16 @@
|
||||
{
|
||||
{
|
||||
"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",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
|
||||
+30
-28
@@ -27,36 +27,37 @@ import (
|
||||
|
||||
// App is the GUI controller shared by Windows WebView and macOS HTTP UI.
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
Mgr *core.Manager
|
||||
CfgPath string
|
||||
LogBuf *bytes.Buffer
|
||||
UpdateStatus update.Status
|
||||
Pings []netcheck.Result
|
||||
OpenURL func(string) error
|
||||
mu sync.Mutex
|
||||
Mgr *core.Manager
|
||||
CfgPath string
|
||||
LogBuf *bytes.Buffer
|
||||
UpdateStatus update.Status
|
||||
Pings []netcheck.Result
|
||||
OpenURL func(string) error
|
||||
OnAfterUpdate func()
|
||||
}
|
||||
|
||||
type UIState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
Subscription string `json:"subscription_url"`
|
||||
SubInfo *config.SubscriptionInfo `json:"sub_info,omitempty"`
|
||||
Remnawave remnawave.Settings `json:"remnawave"`
|
||||
Hy2 core.Hy2Options `json:"hy2"`
|
||||
StorePackaged bool `json:"store_packaged,omitempty"`
|
||||
}
|
||||
|
||||
type PingBestResult struct {
|
||||
@@ -132,6 +133,7 @@ func (a *App) GetState() (UIState, error) {
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
SubInfo: a.Mgr.SubscriptionInfo(),
|
||||
Remnawave: a.Mgr.RemnawaveSettings(),
|
||||
Hy2: a.Mgr.ActiveHy2Options(),
|
||||
StorePackaged: update.IsStorePackaged(),
|
||||
@@ -239,7 +241,7 @@ func (a *App) SaveHy2(opts core.Hy2Options) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -247,7 +249,7 @@ func (a *App) SaveRemnawave(s remnawave.Settings) error {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+120
-4
@@ -507,6 +507,35 @@
|
||||
font-weight: 600;
|
||||
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 input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
@@ -1002,6 +1031,22 @@
|
||||
</details>
|
||||
</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">
|
||||
<span>Системный прокси</span>
|
||||
<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) {
|
||||
const label = "Navis v" + (version || "?");
|
||||
verLabel.textContent = label;
|
||||
@@ -1517,6 +1623,7 @@
|
||||
rememberPings(state.pings || []);
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
renderUpdate(state.update, state.version);
|
||||
renderSubInfo(state.sub_info);
|
||||
if (typeof state.subscription_url === "string" && !dirty) {
|
||||
subUrl.value = state.subscription_url;
|
||||
}
|
||||
@@ -1699,13 +1806,22 @@
|
||||
return;
|
||||
}
|
||||
setMeta("Загрузка подписки…");
|
||||
const n = await importSubscription(url);
|
||||
const r = await importSubscription(url);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
|
||||
setMeta(importSummary(r) + " · измеряю пинг…", "ok");
|
||||
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() {
|
||||
return {
|
||||
base_url: (rwBase && rwBase.value || "").trim(),
|
||||
@@ -1744,10 +1860,10 @@
|
||||
return;
|
||||
}
|
||||
setMeta("Remnawave: загрузка конфигов…");
|
||||
const n = await importRemnawave(s);
|
||||
const r = await importRemnawave(s);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Remnawave: импортировано " + n + " · пинг…", "ok");
|
||||
setMeta("Remnawave: " + importSummary(r).toLowerCase() + " · пинг…", "ok");
|
||||
await runBest(!!autoBest.checked);
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
@@ -39,9 +39,29 @@ type Config struct {
|
||||
RemnawaveShortUUID string `json:"remnawave_short_uuid,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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
@@ -63,9 +83,9 @@ type Profile struct {
|
||||
|
||||
// Hysteria 2 options (client-side). Masquerade on server is separate;
|
||||
// here SNI/insecure/pin emulate HTTPS look + salamander/gecko obfuscation.
|
||||
Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno
|
||||
Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive
|
||||
Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal
|
||||
Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno
|
||||
Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive
|
||||
Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal
|
||||
Hy2BandwidthDown string `json:"hy2_bandwidth_down,omitempty"`
|
||||
Hy2Obfs string `json:"hy2_obfs,omitempty"` // salamander | gecko
|
||||
Hy2ObfsPassword string `json:"hy2_obfs_password,omitempty"`
|
||||
|
||||
+97
-25
@@ -26,14 +26,14 @@ import (
|
||||
|
||||
// Manager orchestrates protocol engines and Windows system proxy.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
engine Engine
|
||||
sys sysproxy.Controller
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
mu sync.Mutex
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
engine Engine
|
||||
sys sysproxy.Controller
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
@@ -439,7 +439,14 @@ func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
|
||||
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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
@@ -452,22 +459,22 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
|
||||
Token: m.RemnawaveSettings().Token,
|
||||
CaddyToken: m.RemnawaveSettings().CaddyToken,
|
||||
}
|
||||
items, used, err := remnawave.Fetch(ctx, s)
|
||||
res, used, err := remnawave.Fetch(ctx, s)
|
||||
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 {
|
||||
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.
|
||||
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)
|
||||
defer cancel()
|
||||
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
|
||||
}
|
||||
}
|
||||
items, used, err := remnawave.Fetch(ctx, s)
|
||||
res, used, err := remnawave.Fetch(ctx, s)
|
||||
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) {
|
||||
if len(items) == 0 {
|
||||
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
||||
func (m *Manager) applySubscription(ctx context.Context, res *subscription.Result, usedURL string, rw *remnawave.Settings) (ImportResult, error) {
|
||||
if res == nil || len(res.Items) == 0 {
|
||||
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()
|
||||
@@ -512,7 +558,10 @@ func (m *Manager) applySubscriptionItems(items []subscription.Item, usedURL stri
|
||||
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)
|
||||
for i := range m.cfg.Profiles {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
// 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.ShortUUID = strings.TrimSpace(s.ShortUUID)
|
||||
s.Token = strings.TrimSpace(s.Token)
|
||||
@@ -105,19 +105,19 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
|
||||
var lastErr error
|
||||
|
||||
// 1) Public subscription (usual end-user path — no token).
|
||||
if items, err := fetchAndParse(ctx, client, s, pub, false); err == nil && len(items) > 0 {
|
||||
return items, pub, nil
|
||||
if res, err := fetchAndParse(ctx, client, s, pub, false); err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, pub, nil
|
||||
} else if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 2) With API token: resolve subscriptionUrl / raw payload.
|
||||
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 == "" {
|
||||
used = pub
|
||||
}
|
||||
return items, used, nil
|
||||
return res, used, nil
|
||||
} else if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func Fetch(ctx context.Context, s Settings) ([]subscription.Item, string, error)
|
||||
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
|
||||
short := s.ShortUUID
|
||||
|
||||
@@ -142,18 +142,18 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
|
||||
}
|
||||
var lastErr error
|
||||
for _, endpoint := range candidates {
|
||||
body, err := doGET(ctx, client, s, endpoint, true)
|
||||
body, _, err := doGET(ctx, client, s, endpoint, true)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if items, err := parseRemnawaveBody(body); err == nil && len(items) > 0 {
|
||||
return items, endpoint, nil
|
||||
if res, err := parseRemnawaveBody(body); err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, endpoint, nil
|
||||
}
|
||||
if subURL := extractSubscriptionURL(body); subURL != "" {
|
||||
items, err := subscription.Fetch(ctx, subURL)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, subURL, nil
|
||||
res, err := subscription.Fetch(ctx, subURL)
|
||||
if err == nil && res != nil && len(res.Items) > 0 {
|
||||
return res, subURL, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
@@ -166,18 +166,22 @@ func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]sub
|
||||
return nil, "", fmt.Errorf("Remnawave API: не удалось получить конфиги")
|
||||
}
|
||||
|
||||
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) ([]subscription.Item, error) {
|
||||
body, err := doGET(ctx, client, s, endpoint, auth)
|
||||
func fetchAndParse(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (*subscription.Result, error) {
|
||||
body, header, err := doGET(ctx, client, s, endpoint, auth)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)")
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Remnawave запрос: %w", err)
|
||||
return "", nil, fmt.Errorf("Remnawave запрос: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(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 {
|
||||
@@ -228,24 +232,24 @@ func shouldSendProxyHeaders(u *url.URL) bool {
|
||||
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)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустой ответ Remnawave")
|
||||
}
|
||||
// Try JSON envelope / raw DTO first, then classic subscription parse.
|
||||
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
|
||||
if items, err := subscription.ParseBody(flattenJSONLinks(body)); err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
if res, err := subscription.ParseBodyDetailed(flattenJSONLinks(body)); err == nil && len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
// Also try parsing the whole JSON as if links were string values only.
|
||||
if flat := extractLinkStrings(body); flat != "" {
|
||||
if items, err := subscription.ParseBody(flat); err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
if res, err := subscription.ParseBodyDetailed(flat); err == nil && len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return subscription.ParseBody(body)
|
||||
return subscription.ParseBodyDetailed(body)
|
||||
}
|
||||
|
||||
func flattenJSONLinks(body string) string {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+216
-45
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -25,13 +26,45 @@ type Item struct {
|
||||
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 (
|
||||
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"']+)`)
|
||||
// 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.
|
||||
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)
|
||||
if rawURL == "" {
|
||||
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 подписки")
|
||||
}
|
||||
|
||||
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)
|
||||
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", "*/*")
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось скачать подписку: %w", err)
|
||||
return "", nil, fmt.Errorf("не удалось скачать подписку: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
items, err := ParseBody(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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
|
||||
}
|
||||
return items, 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 {
|
||||
continue
|
||||
}
|
||||
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.
|
||||
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)
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("пустая подписка")
|
||||
return &Result{}, fmt.Errorf("пустая подписка")
|
||||
}
|
||||
|
||||
candidates := []string{body}
|
||||
@@ -83,56 +195,68 @@ func ParseBody(body string) ([]Item, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var best *Result
|
||||
for _, text := range candidates {
|
||||
items, err := parseText(text)
|
||||
if err == nil && len(items) > 0 {
|
||||
return items, nil
|
||||
res := parseText(text)
|
||||
if len(res.Items) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if best == nil || res.Skipped > best.Skipped {
|
||||
best = res
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
if best == nil {
|
||||
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) {
|
||||
lines := splitLines(text)
|
||||
// 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))
|
||||
func parseText(text string) *Result {
|
||||
res := &Result{}
|
||||
seen := map[string]int{}
|
||||
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)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
return
|
||||
}
|
||||
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)
|
||||
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+") {
|
||||
continue
|
||||
skip("нет логина и пароля в ссылке")
|
||||
return
|
||||
}
|
||||
normalized, detected, remark, err := linknorm.Normalize(proto, line)
|
||||
if err != nil {
|
||||
continue
|
||||
skip(err.Error())
|
||||
return
|
||||
}
|
||||
if _, dup := seenURI[normalized]; dup {
|
||||
continue
|
||||
return
|
||||
}
|
||||
seenURI[normalized] = struct{}{}
|
||||
name := remark
|
||||
@@ -147,12 +271,33 @@ func parseText(text string) ([]Item, error) {
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
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 {
|
||||
@@ -175,7 +320,7 @@ func extractClashHY2(text string) []string {
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := strings.Split(text, "\n- ")
|
||||
chunks := splitClashItems(text)
|
||||
for _, chunk := range chunks {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
@@ -223,8 +368,34 @@ func extractClashHY2(text string) []string {
|
||||
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 {
|
||||
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)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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) {
|
||||
body := `
|
||||
proxies:
|
||||
|
||||
@@ -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 = 1
|
||||
const BuildNumber = 2
|
||||
|
||||
// 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.1"
|
||||
Version="3.9.0.2"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
param(
|
||||
[string]$ExePath = "",
|
||||
[string]$OutDir = "",
|
||||
[string]$Version = "3.9.0.1",
|
||||
[string]$Version = "3.9.0.2",
|
||||
[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.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('<PublisherDisplayName>EvilFox</PublisherDisplayName>', "<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>")
|
||||
$manifestText = $manifestText.Replace('DisplayName="Navis"', "DisplayName=`"$DisplayName`"")
|
||||
|
||||
+4
-4
@@ -1,16 +1,16 @@
|
||||
{
|
||||
{
|
||||
"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",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/windows/Navis.exe",
|
||||
"sha256": "ba2d2860c771dc99081b4fa9cea147a8da4495a93c55aacfb11e6114e3a7a614",
|
||||
"sha256": "cba03d356be00b4bae9a2ffb013f035ecc2cd9c167c02aa1b2afee08576920f7",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 3, "Minor": 9, "Patch": 0, "Build": 1 },
|
||||
"ProductVersion": { "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": 2 },
|
||||
"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.1",
|
||||
"FileVersion": "3.9.0.2",
|
||||
"InternalName": "Navis",
|
||||
"LegalCopyright": "Copyright (c) EvilFox",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "3.9.0.1",
|
||||
"ProductVersion": "3.9.0.2",
|
||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
Reference in New Issue
Block a user