Release 3.9.0+1: Remnawave API configs, Remnawave-style UI with flags, Windows build.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 16:17:10 +03:00
co-authored by Cursor
parent 92cf707597
commit 015ded9bd5
24 changed files with 985 additions and 159 deletions
+23
View File
@@ -21,6 +21,7 @@ import (
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/remnawave"
"vpnclient/internal/update"
)
@@ -53,6 +54,7 @@ type UIState struct {
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"`
}
@@ -130,6 +132,7 @@ func (a *App) GetState() (UIState, error) {
Update: a.UpdateStatus,
Pings: append([]netcheck.Result(nil), a.Pings...),
Subscription: cfg.SubscriptionURL,
Remnawave: a.Mgr.RemnawaveSettings(),
Hy2: a.Mgr.ActiveHy2Options(),
StorePackaged: update.IsStorePackaged(),
}
@@ -240,6 +243,14 @@ func (a *App) ImportSubscription(rawURL string) (int, error) {
return a.Mgr.ImportSubscription(rawURL)
}
func (a *App) SaveRemnawave(s remnawave.Settings) error {
return a.Mgr.SaveRemnawaveSettings(s)
}
func (a *App) ImportRemnawave(s remnawave.Settings) (int, error) {
return a.Mgr.ImportRemnawave(s)
}
func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
@@ -486,6 +497,18 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "saveRemnawave":
var s remnawave.Settings
if len(args) > 0 {
_ = json.Unmarshal(args[0], &s)
}
return nil, a.SaveRemnawave(s)
case "importRemnawave":
var s remnawave.Settings
if len(args) > 0 {
_ = json.Unmarshal(args[0], &s)
}
return a.ImportRemnawave(s)
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
+265 -125
View File
@@ -10,125 +10,126 @@
var k = "navis.theme";
var t = localStorage.getItem(k);
if (t !== "light" && t !== "dark") {
t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
t = "dark";
}
document.documentElement.setAttribute("data-theme", t);
} catch (_) {
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.setAttribute("data-theme", "dark");
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
/* Remnawave-inspired: dark-first slate + cyan accent (not purple slop) */
:root {
color-scheme: light;
--ink: #0b1c18;
--ink: #0f172a;
--text: var(--ink);
--muted: #5a736b;
--line: rgba(11, 28, 24, 0.10);
--accent: #0d8a66;
--accent-deep: #086b4f;
--accent-soft: #d8f3e9;
--danger: #c0362c;
--ok: #0d8a66;
--ms-good: #0a7a3e;
--ms-mid: #b8860b;
--surface: rgba(255, 255, 255, 0.82);
--surface-2: rgba(255, 255, 255, 0.62);
--input-bg: rgba(255, 255, 255, 0.94);
--row-bg: rgba(255, 255, 255, 0.6);
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
--hero-bg-on: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
--chip-bg: rgba(255, 255, 255, 0.75);
--server-list-bg: rgba(255, 255, 255, 0.4);
--server-row-bg: rgba(255, 255, 255, 0.72);
--muted: #64748b;
--line: rgba(15, 23, 42, 0.10);
--accent: #0891b2;
--accent-deep: #0e7490;
--accent-soft: #cffafe;
--danger: #dc2626;
--ok: #0d9488;
--ms-good: #059669;
--ms-mid: #d97706;
--surface: rgba(255, 255, 255, 0.88);
--surface-2: rgba(248, 250, 252, 0.92);
--input-bg: #ffffff;
--row-bg: rgba(255, 255, 255, 0.7);
--hero-bg: linear-gradient(155deg, #f8fafc 0%, #ecfeff 55%, #f1f5f9 100%);
--hero-bg-on: linear-gradient(155deg, #ecfeff 0%, #e0f2fe 50%, #f8fafc 100%);
--chip-bg: rgba(255, 255, 255, 0.85);
--server-list-bg: rgba(241, 245, 249, 0.65);
--server-row-bg: rgba(255, 255, 255, 0.92);
--server-row-hover: #fff;
--server-row-active: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
--meta-bg: rgba(255, 255, 255, 0.35);
--meta-ok-bg: rgba(216, 243, 233, 0.65);
--meta-err-bg: rgba(255, 236, 234, 0.75);
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
--shop-border: rgba(13, 138, 102, 0.16);
--modal-bg: #f5fbf8;
--modal-scrim: rgba(8, 28, 24, 0.42);
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
--update-border: rgba(13, 138, 102, 0.28);
--icon-btn-bg: linear-gradient(180deg, #d2efe4, #bfe6d6);
--icon-btn-border: rgba(8, 90, 68, 0.22);
--icon-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
--mini-bg: linear-gradient(180deg, #d2efe4, #bfe6d6);
--mini-accent-bg: linear-gradient(180deg, #2f6f5f, #25584b);
--mini-accent-fg: #f2fff9;
--switch-off: #c7d5cf;
--server-row-active: linear-gradient(135deg, #ecfeff, #fff);
--meta-bg: rgba(241, 245, 249, 0.8);
--meta-ok-bg: rgba(204, 251, 241, 0.75);
--meta-err-bg: rgba(254, 226, 226, 0.85);
--shop-bg: linear-gradient(135deg, rgba(8,145,178,.08), rgba(255,255,255,.7));
--shop-border: rgba(8, 145, 178, 0.18);
--modal-bg: #f8fafc;
--modal-scrim: rgba(15, 23, 42, 0.45);
--update-bg: linear-gradient(135deg, rgba(8,145,178,.12), rgba(255,255,255,.85));
--update-border: rgba(8, 145, 178, 0.28);
--icon-btn-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--icon-btn-border: rgba(15, 23, 42, 0.12);
--icon-btn-hover: linear-gradient(180deg, #f1f5f9, #e2e8f0);
--mini-bg: linear-gradient(180deg, #e2e8f0, #cbd5e1);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #f0fdfa;
--switch-off: #cbd5e1;
--switch-knob: #fff;
--dot-off: #9aaba4;
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
--radius: 22px;
--dot-off: #94a3b8;
--shadow: 0 20px 50px rgba(15, 23, 42, 0.10);
--radius: 18px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
--theme-toggle-bg: rgba(255, 255, 255, 0.7);
radial-gradient(700px 380px at 0% -5%, #a5f3fc 0%, transparent 50%),
radial-gradient(560px 320px at 100% 0%, #bae6fd 0%, transparent 45%),
linear-gradient(165deg, #f1f5f9 0%, #f8fafc 50%, #eef2ff 100%);
--theme-toggle-bg: rgba(255, 255, 255, 0.75);
--sidebar-rail: rgba(8, 145, 178, 0.55);
}
html[data-theme="dark"] {
color-scheme: dark;
--ink: #e4f2eb;
--ink: #e8eef7;
--text: var(--ink);
--muted: #8fa89c;
--line: rgba(180, 220, 200, 0.12);
--accent: #2ec995;
--accent-deep: #1aa877;
--accent-soft: rgba(46, 201, 149, 0.16);
--danger: #e07068;
--ok: #2ec995;
--ms-good: #3dd68c;
--ms-mid: #d4a017;
--surface: rgba(18, 32, 28, 0.88);
--surface-2: rgba(24, 40, 35, 0.72);
--input-bg: rgba(12, 24, 20, 0.92);
--row-bg: rgba(22, 36, 31, 0.75);
--hero-bg: linear-gradient(160deg, rgba(22,38,33,.95), rgba(16,28,24,.9));
--hero-bg-on: linear-gradient(160deg, rgba(18,48,40,.96), rgba(22,38,33,.92));
--chip-bg: rgba(28, 44, 38, 0.85);
--server-list-bg: rgba(10, 20, 17, 0.55);
--server-row-bg: rgba(22, 36, 31, 0.85);
--server-row-hover: rgba(30, 48, 41, 0.95);
--server-row-active: linear-gradient(135deg, rgba(18,52,42,.95), rgba(24,40,35,.95));
--meta-bg: rgba(14, 26, 22, 0.55);
--meta-ok-bg: rgba(18, 52, 42, 0.65);
--meta-err-bg: rgba(56, 28, 26, 0.7);
--shop-bg: linear-gradient(135deg, rgba(46,201,149,.1), rgba(18,32,28,.7));
--shop-border: rgba(46, 201, 149, 0.2);
--modal-bg: #15251f;
--modal-scrim: rgba(4, 12, 10, 0.62);
--update-bg: linear-gradient(135deg, rgba(46,201,149,.16), rgba(18,32,28,.85));
--update-border: rgba(46, 201, 149, 0.28);
--icon-btn-bg: linear-gradient(180deg, #1e3d34, #17332b);
--icon-btn-border: rgba(46, 201, 149, 0.28);
--icon-btn-hover: linear-gradient(180deg, #255045, #1c3c33);
--mini-bg: linear-gradient(180deg, #1e3d34, #17332b);
--mini-accent-bg: linear-gradient(180deg, #1aa877, #0d8a66);
--mini-accent-fg: #f2fff9;
--switch-off: #3a4f47;
--switch-knob: #e8f5ef;
--dot-off: #5a736b;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
--muted: #8b9bb4;
--line: rgba(148, 163, 184, 0.14);
--accent: #22d3ee;
--accent-deep: #06b6d4;
--accent-soft: rgba(34, 211, 238, 0.12);
--danger: #f87171;
--ok: #2dd4bf;
--ms-good: #34d399;
--ms-mid: #fbbf24;
--surface: rgba(15, 20, 30, 0.92);
--surface-2: rgba(22, 30, 44, 0.88);
--input-bg: rgba(10, 14, 22, 0.95);
--row-bg: rgba(20, 28, 40, 0.8);
--hero-bg: linear-gradient(155deg, #121826 0%, #0f172a 55%, #111827 100%);
--hero-bg-on: linear-gradient(155deg, #0c2a32 0%, #132033 50%, #121826 100%);
--chip-bg: rgba(30, 41, 59, 0.9);
--server-list-bg: rgba(8, 12, 20, 0.55);
--server-row-bg: rgba(22, 30, 44, 0.9);
--server-row-hover: rgba(30, 41, 59, 0.95);
--server-row-active: linear-gradient(135deg, rgba(8,47,55,.95), rgba(22,30,44,.95));
--meta-bg: rgba(10, 14, 22, 0.7);
--meta-ok-bg: rgba(6, 78, 72, 0.45);
--meta-err-bg: rgba(69, 26, 26, 0.55);
--shop-bg: linear-gradient(135deg, rgba(34,211,238,.08), rgba(15,20,30,.85));
--shop-border: rgba(34, 211, 238, 0.18);
--modal-bg: #121826;
--modal-scrim: rgba(2, 6, 14, 0.72);
--update-bg: linear-gradient(135deg, rgba(34,211,238,.12), rgba(15,20,30,.9));
--update-border: rgba(34, 211, 238, 0.28);
--icon-btn-bg: linear-gradient(180deg, #1e293b, #162032);
--icon-btn-border: rgba(34, 211, 238, 0.22);
--icon-btn-hover: linear-gradient(180deg, #243247, #1a2536);
--mini-bg: linear-gradient(180deg, #1e293b, #162032);
--mini-accent-bg: linear-gradient(180deg, #0891b2, #0e7490);
--mini-accent-fg: #ecfeff;
--switch-off: #334155;
--switch-knob: #e2e8f0;
--dot-off: #64748b;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.5);
--page-bg:
radial-gradient(720px 400px at 6% -8%, rgba(26, 90, 70, 0.45) 0%, transparent 55%),
radial-gradient(560px 340px at 100% 0%, rgba(28, 56, 72, 0.4) 0%, transparent 48%),
radial-gradient(480px 300px at 50% 110%, rgba(18, 64, 48, 0.35) 0%, transparent 45%),
linear-gradient(165deg, #0c1613 0%, #121f1a 48%, #101a1c 100%);
--theme-toggle-bg: rgba(28, 44, 38, 0.85);
radial-gradient(680px 380px at 5% -8%, rgba(8, 90, 110, 0.35) 0%, transparent 55%),
radial-gradient(520px 300px at 100% 0%, rgba(30, 58, 95, 0.32) 0%, transparent 48%),
linear-gradient(165deg, #070b12 0%, #0c1220 48%, #0a1018 100%);
--theme-toggle-bg: rgba(30, 41, 59, 0.85);
--sidebar-rail: rgba(34, 211, 238, 0.45);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
font-family: Figtree, sans-serif;
font-family: Manrope, sans-serif;
color: var(--ink);
background: var(--page-bg);
}
@@ -138,14 +139,24 @@
padding: 16px 12px 20px;
}
.shell {
width: min(480px, 100%);
width: min(500px, 100%);
background: var(--surface);
backdrop-filter: blur(18px) saturate(1.15);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border-radius: 28px;
padding: 18px 18px 16px;
border-radius: 22px;
padding: 16px 16px 14px;
animation: rise .55s var(--ease) both;
position: relative;
overflow: hidden;
}
.shell::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--accent), transparent 70%);
opacity: .9;
}
@keyframes rise {
from { opacity: 0; transform: translateY(14px) scale(.985); }
@@ -163,7 +174,7 @@
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
display: block;
margin-bottom: 4px;
font-size: .95rem;
@@ -190,13 +201,13 @@
margin-bottom: 14px;
}
.logo {
width: 52px;
height: 52px;
border-radius: 16px;
width: 48px;
height: 48px;
border-radius: 14px;
overflow: hidden;
flex: 0 0 auto;
box-shadow: 0 10px 24px rgba(13, 138, 102, 0.28);
background: linear-gradient(145deg, #0d8a66, #1bb887);
box-shadow: 0 10px 24px rgba(8, 145, 178, 0.28);
background: linear-gradient(145deg, #0e7490, #22d3ee);
display: grid;
place-items: center;
}
@@ -209,7 +220,7 @@
flex-wrap: wrap;
}
.brand {
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.045em;
@@ -223,8 +234,8 @@
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(13,138,102,.18);
border-radius: 999px;
border: 1px solid rgba(8,145,178,.22);
border-radius: 8px;
padding: 3px 8px;
}
.tagline {
@@ -248,7 +259,7 @@
padding: 0;
}
.theme-toggle:hover {
border-color: rgba(13, 138, 102, 0.35);
border-color: rgba(8, 145, 178, 0.4);
transform: translateY(-1px);
}
.theme-toggle:active { transform: none; }
@@ -351,7 +362,7 @@
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
@@ -560,7 +571,7 @@
width: 100%;
min-height: 52px;
font-size: 1.02rem;
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
}
.actions { display: grid; gap: 8px; }
@@ -642,15 +653,38 @@
color: inherit;
}
.server-row:hover {
border-color: rgba(13,138,102,.25);
border-color: rgba(8,145,178,.3);
background: var(--server-row-hover);
}
.server-row.active {
border-color: rgba(13,138,102,.4);
border-color: rgba(8,145,178,.45);
background: var(--server-row-active);
box-shadow: 0 4px 14px rgba(13,138,102,.1);
box-shadow: 0 4px 14px rgba(8,145,178,.12);
}
.server-row .left { min-width: 0; }
.server-row .left {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.server-row .flag {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 1.1rem;
line-height: 1;
background: var(--chip-bg);
border: 1px solid var(--line);
}
.server-row .flag.unknown {
font-size: .7rem;
font-weight: 700;
color: var(--muted);
}
.server-row .meta-col { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
@@ -693,6 +727,27 @@
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.remna-box {
margin-top: 4px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
display: grid;
gap: 8px;
}
.remna-title {
font-family: Outfit, sans-serif;
font-weight: 700;
font-size: .92rem;
letter-spacing: -.02em;
}
.remna-hint {
margin: 0;
font-size: .75rem;
color: var(--muted);
line-height: 1.4;
}
.server-empty {
padding: 14px 10px;
text-align: center;
@@ -759,7 +814,7 @@
background: var(--shop-bg);
}
.shop h3 {
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
font-size: 1rem;
letter-spacing: -0.02em;
margin: 0 0 5px;
@@ -815,7 +870,7 @@
animation: rise .35s var(--ease) both;
}
.modal h2 {
font-family: Sora, sans-serif;
font-family: Outfit, sans-serif;
margin: 0 0 12px;
font-size: 1.2rem;
letter-spacing: -.02em;
@@ -854,9 +909,9 @@
<div class="brand-wrap">
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver" id="badgeVer">3.8.2</span>
<span class="badge-ver" id="badgeVer">3.9.0</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
<p class="tagline">Remnawave · Naive · Hy2 · AWG · Xray</p>
</div>
<button type="button" class="theme-toggle" id="themeToggle" title="Тема" aria-label="Переключить тему">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -901,7 +956,7 @@
<select id="profile" hidden></select>
<details class="profile-edit" id="profileEdit">
<summary>Редактировать выбранный / подписка</summary>
<summary>Редактировать / подписка / Remnawave</summary>
<div class="stack" style="margin-top:8px">
<div>
<label class="field" for="name">Название</label>
@@ -912,12 +967,36 @@
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<div>
<label class="field" for="subUrl">Подписка</label>
<label class="field" for="subUrl">URL подписки</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<input id="subUrl" type="text" placeholder="https://…/api/sub/… или обычная подписка" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
</div>
</div>
<div class="remna-box">
<div class="remna-title">Remnawave API</div>
<p class="remna-hint">Панель → short UUID пользователя. Токен API — опционально (для /api/subscriptions/… и /users/…).</p>
<div>
<label class="field" for="rwBase">URL панели</label>
<input id="rwBase" type="text" placeholder="https://panel.example.com" spellcheck="false" />
</div>
<div>
<label class="field" for="rwShort">Short UUID</label>
<input id="rwShort" type="text" placeholder="abc12xyz" spellcheck="false" />
</div>
<div>
<label class="field" for="rwToken">API token</label>
<input id="rwToken" type="password" placeholder="Bearer из панели → API Tokens" spellcheck="false" autocomplete="off" />
</div>
<div>
<label class="field" for="rwCaddy">Caddy X-Api-Key</label>
<input id="rwCaddy" type="password" placeholder="если стоит auth у Caddy" spellcheck="false" autocomplete="off" />
</div>
<div class="sub-bar" style="margin-top:8px">
<button class="action secondary" id="rwSaveBtn" type="button" style="flex:1">Сохранить</button>
<button class="action primary" id="rwSyncBtn" type="button" style="flex:1">Синхронизировать</button>
</div>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</details>
@@ -1034,7 +1113,7 @@
const methods = [
"getState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","quit"
"checkUpdate","applyUpdate","saveHy2","importSubscription","saveRemnawave","importRemnawave","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
@@ -1094,7 +1173,7 @@
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (_) {
return "light";
return "dark";
}
}
function applyTheme(t) {
@@ -1136,6 +1215,12 @@
}
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const rwBase = $("rwBase");
const rwShort = $("rwShort");
const rwToken = $("rwToken");
const rwCaddy = $("rwCaddy");
const rwSaveBtn = $("rwSaveBtn");
const rwSyncBtn = $("rwSyncBtn");
const hero = $("hero");
const protoChip = $("protoChip");
const heroHint = $("heroHint");
@@ -1201,7 +1286,7 @@
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
proxy, nameInput, sysproxy, subUrl, rwBase, rwShort, rwToken, rwCaddy,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
@@ -1291,14 +1376,22 @@
const left = document.createElement("div");
left.className = "left";
const flag = document.createElement("span");
flag.className = "flag" + (p.flag ? "" : " unknown");
flag.textContent = p.flag || (p.country || "·");
flag.title = p.country || "регион неизвестен";
const metaCol = document.createElement("div");
metaCol.className = "meta-col";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
left.appendChild(nameEl);
left.appendChild(sub);
metaCol.appendChild(nameEl);
metaCol.appendChild(sub);
left.appendChild(flag);
left.appendChild(metaCol);
const right = document.createElement("div");
right.className = "right";
@@ -1413,6 +1506,12 @@
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
if (rwBase) rwBase.disabled = busy;
if (rwShort) rwShort.disabled = busy;
if (rwToken) rwToken.disabled = busy;
if (rwCaddy) rwCaddy.disabled = busy;
if (rwSaveBtn) rwSaveBtn.disabled = busy;
if (rwSyncBtn) rwSyncBtn.disabled = busy;
btn.disabled = busy;
rememberPings(state.pings || []);
@@ -1421,6 +1520,12 @@
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (state.remnawave && !dirty) {
rwBase.value = state.remnawave.base_url || "";
rwShort.value = state.remnawave.short_uuid || "";
if (typeof state.remnawave.token === "string") rwToken.value = state.remnawave.token;
if (typeof state.remnawave.caddy_token === "string") rwCaddy.value = state.remnawave.caddy_token;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
@@ -1484,7 +1589,7 @@
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl, rwBase, rwShort, rwToken, rwCaddy, rwSaveBtn, rwSyncBtn].forEach((b) => {
if (b) b.disabled = locked;
});
}
@@ -1601,6 +1706,15 @@
await runBest(!!autoBest.checked);
}
function readRemnawave() {
return {
base_url: (rwBase && rwBase.value || "").trim(),
short_uuid: (rwShort && rwShort.value || "").trim(),
token: (rwToken && rwToken.value || "").trim(),
caddy_token: (rwCaddy && rwCaddy.value || "").trim()
};
}
subBtn.addEventListener("click", () => withBusy(async () => {
try {
await runImportSubscription();
@@ -1613,6 +1727,32 @@
}
});
if (rwSaveBtn) {
rwSaveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveRemnawave(readRemnawave());
setMeta("Настройки Remnawave сохранены", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
}
if (rwSyncBtn) {
rwSyncBtn.addEventListener("click", () => withBusy(async () => {
try {
const s = readRemnawave();
if (!s.base_url || !s.short_uuid) {
setMeta("Укажите URL панели и short UUID", "err");
return;
}
setMeta("Remnawave: загрузка конфигов…");
const n = await importRemnawave(s);
formHydrated = false;
dirty = false;
setMeta("Remnawave: импортировано " + n + " · пинг…", "ok");
await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); }
}));
}
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Пинг серверов…");
+6
View File
@@ -33,6 +33,12 @@ type Config struct {
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
// Remnawave panel integration (local only — do not commit tokens).
RemnawaveBaseURL string `json:"remnawave_base_url,omitempty"`
RemnawaveToken string `json:"remnawave_token,omitempty"`
RemnawaveShortUUID string `json:"remnawave_short_uuid,omitempty"`
RemnawaveCaddyToken string `json:"remnawave_caddy_token,omitempty"`
Profiles []Profile `json:"profiles"`
}
+10
View File
@@ -3,6 +3,8 @@ package config
import (
"fmt"
"strings"
"vpnclient/internal/geoflag"
)
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
@@ -12,18 +14,26 @@ type ProfileInfo struct {
Proxy string `json:"proxy"`
Host string `json:"host"`
Active bool `json:"active"`
Country string `json:"country,omitempty"` // ISO 3166-1 alpha-2
Flag string `json:"flag,omitempty"` // emoji flag or empty
}
// ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
for _, p := range c.Profiles {
cc, flag := geoflag.FromText(p.Name)
if cc == "" {
cc, flag = geoflag.FromText(proxyHost(p.Proxy))
}
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Proxy: p.Proxy,
Host: proxyHost(p.Proxy),
Active: p.Name == c.Active,
Country: cc,
Flag: flag,
})
}
return out
+82 -1
View File
@@ -19,6 +19,7 @@ import (
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/remnawave"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy"
)
@@ -417,20 +418,100 @@ func (m *Manager) SubscriptionURL() string {
return m.cfg.SubscriptionURL
}
func (m *Manager) RemnawaveSettings() remnawave.Settings {
m.mu.Lock()
defer m.mu.Unlock()
return remnawave.Settings{
BaseURL: m.cfg.RemnawaveBaseURL,
Token: m.cfg.RemnawaveToken,
ShortUUID: m.cfg.RemnawaveShortUUID,
CaddyToken: m.cfg.RemnawaveCaddyToken,
}
}
func (m *Manager) SaveRemnawaveSettings(s remnawave.Settings) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(s.BaseURL)
m.cfg.RemnawaveToken = strings.TrimSpace(s.Token)
m.cfg.RemnawaveShortUUID = strings.TrimSpace(s.ShortUUID)
m.cfg.RemnawaveCaddyToken = strings.TrimSpace(s.CaddyToken)
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
rawURL = strings.TrimSpace(rawURL)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
// Remnawave-style URL → persist panel coords and fetch via API helper.
if base, short, ok := remnawave.ParseInput(rawURL); ok {
s := remnawave.Settings{
BaseURL: base,
ShortUUID: short,
Token: m.RemnawaveSettings().Token,
CaddyToken: m.RemnawaveSettings().CaddyToken,
}
items, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return 0, err
}
return m.applySubscriptionItems(items, used, &s)
}
items, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return 0, err
}
return m.applySubscriptionItems(items, rawURL, nil)
}
// ImportRemnawave fetches configs using saved / provided panel settings.
func (m *Manager) ImportRemnawave(s remnawave.Settings) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.ShortUUID) == "" {
cur := m.RemnawaveSettings()
if strings.TrimSpace(s.BaseURL) == "" {
s.BaseURL = cur.BaseURL
}
if strings.TrimSpace(s.ShortUUID) == "" {
s.ShortUUID = cur.ShortUUID
}
if strings.TrimSpace(s.Token) == "" {
s.Token = cur.Token
}
if strings.TrimSpace(s.CaddyToken) == "" {
s.CaddyToken = cur.CaddyToken
}
}
items, used, err := remnawave.Fetch(ctx, s)
if err != nil {
return 0, err
}
return m.applySubscriptionItems(items, 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)")
}
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
if usedURL != "" {
m.cfg.SubscriptionURL = strings.TrimSpace(usedURL)
}
if rw != nil {
m.cfg.RemnawaveBaseURL = remnawave.NormalizeBase(rw.BaseURL)
m.cfg.RemnawaveShortUUID = strings.TrimSpace(rw.ShortUUID)
if t := strings.TrimSpace(rw.Token); t != "" {
m.cfg.RemnawaveToken = t
}
if t := strings.TrimSpace(rw.CaddyToken); t != "" {
m.cfg.RemnawaveCaddyToken = t
}
}
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
+134
View File
@@ -0,0 +1,134 @@
// Package geoflag maps server/profile names to ISO 3166-1 alpha-2 and emoji flags.
package geoflag
import (
"regexp"
"strings"
"unicode"
)
var (
reBracketCC = regexp.MustCompile(`(?i)[\[(]([a-z]{2})[\])]`)
rePrefixCC = regexp.MustCompile(`(?i)^([a-z]{2})(?:\s*[-_|/]\s*|\s+)`)
reTokenCC = regexp.MustCompile(`(?i)(?:^|[^a-z])([a-z]{2})(?:\s*[-_|]|$)`)
reEmojiFlag = regexp.MustCompile(`[\x{1F1E6}-\x{1F1FF}]{2}`)
)
// FromText returns ISO2 (upper) and emoji flag, or empty if unknown.
func FromText(name string) (iso2, emoji string) {
name = strings.TrimSpace(name)
if name == "" {
return "", ""
}
if m := reEmojiFlag.FindString(name); m != "" {
if cc := emojiToISO(m); cc != "" {
return cc, m
}
}
lower := strings.ToLower(name)
for _, pair := range countryNames {
if strings.Contains(lower, pair.name) {
return pair.code, ISOToEmoji(pair.code)
}
}
if m := reBracketCC.FindStringSubmatch(name); len(m) == 2 {
cc := strings.ToUpper(m[1])
if Known(cc) {
return cc, ISOToEmoji(cc)
}
}
if m := rePrefixCC.FindStringSubmatch(name); len(m) == 2 {
cc := strings.ToUpper(m[1])
if Known(cc) {
return cc, ISOToEmoji(cc)
}
}
if m := reTokenCC.FindStringSubmatch(name); len(m) == 2 {
cc := strings.ToUpper(m[1])
if Known(cc) {
return cc, ISOToEmoji(cc)
}
}
return "", ""
}
// Known reports whether cc is a mapped ISO2 code.
func Known(cc string) bool {
cc = strings.ToUpper(strings.TrimSpace(cc))
_, ok := isoSet[cc]
return ok
}
// ISOToEmoji converts "DE" → "🇩🇪". Invalid → "".
func ISOToEmoji(cc string) string {
cc = strings.ToUpper(strings.TrimSpace(cc))
if len(cc) != 2 {
return ""
}
a, b := rune(cc[0]), rune(cc[1])
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
return ""
}
return string(rune(0x1F1E6+a-'A')) + string(rune(0x1F1E6+b-'A'))
}
func emojiToISO(flag string) string {
rs := []rune(flag)
if len(rs) != 2 {
return ""
}
a, b := rs[0]-0x1F1E6, rs[1]-0x1F1E6
if a < 0 || a > 25 || b < 0 || b > 25 {
return ""
}
cc := string([]byte{byte('A' + a), byte('A' + b)})
if !Known(cc) {
// Still return regional indicator pair as ISO guess for display.
if unicode.IsLetter(rune('A'+a)) {
return cc
}
return ""
}
return cc
}
type nameCode struct {
name string
code string
}
// Longer / more specific names first where needed.
var countryNames = []nameCode{
{"netherlands", "NL"}, {"deutschland", "DE"}, {"germany", "DE"},
{"united kingdom", "GB"}, {"great britain", "GB"}, {"england", "GB"},
{"united states", "US"}, {"america", "US"}, {"finland", "FI"},
{"sweden", "SE"}, {"norway", "NO"}, {"denmark", "DK"}, {"poland", "PL"},
{"france", "FR"}, {"spain", "ES"}, {"italy", "IT"}, {"portugal", "PT"},
{"switzerland", "CH"}, {"austria", "AT"}, {"belgium", "BE"},
{"czech", "CZ"}, {"slovakia", "SK"}, {"hungary", "HU"}, {"romania", "RO"},
{"bulgaria", "BG"}, {"ukraine", "UA"}, {"russia", "RU"}, {"turkey", "TR"},
{"israel", "IL"}, {"uae", "AE"}, {"emirates", "AE"}, {"dubai", "AE"},
{"singapore", "SG"}, {"hong kong", "HK"}, {"japan", "JP"}, {"korea", "KR"},
{"taiwan", "TW"}, {"india", "IN"}, {"canada", "CA"}, {"mexico", "MX"},
{"brazil", "BR"}, {"argentina", "AR"}, {"australia", "AU"},
{"new zealand", "NZ"}, {"latvia", "LV"}, {"lithuania", "LT"},
{"estonia", "EE"}, {"moldova", "MD"}, {"georgia", "GE"}, {"armenia", "AM"},
{"kazakhstan", "KZ"}, {"uzbekistan", "UZ"}, {"serbia", "RS"},
{"croatia", "HR"}, {"slovenia", "SI"}, {"greece", "GR"}, {"cyprus", "CY"},
{"ireland", "IE"}, {"iceland", "IS"}, {"luxembourg", "LU"},
{"malaysia", "MY"}, {"thailand", "TH"}, {"vietnam", "VN"},
{"indonesia", "ID"}, {"philippines", "PH"}, {"south africa", "ZA"},
}
var isoSet = map[string]struct{}{
"AD": {}, "AE": {}, "AF": {}, "AL": {}, "AM": {}, "AR": {}, "AT": {}, "AU": {},
"AZ": {}, "BA": {}, "BD": {}, "BE": {}, "BG": {}, "BH": {}, "BR": {}, "BY": {},
"CA": {}, "CH": {}, "CL": {}, "CN": {}, "CO": {}, "CY": {}, "CZ": {}, "DE": {},
"DK": {}, "EE": {}, "EG": {}, "ES": {}, "FI": {}, "FR": {}, "GB": {}, "GE": {},
"GR": {}, "HK": {}, "HR": {}, "HU": {}, "ID": {}, "IE": {}, "IL": {}, "IN": {},
"IQ": {}, "IR": {}, "IS": {}, "IT": {}, "JP": {}, "KG": {}, "KR": {}, "KZ": {},
"LT": {}, "LU": {}, "LV": {}, "MD": {}, "MK": {}, "MX": {}, "MY": {}, "NL": {},
"NO": {}, "NZ": {}, "PH": {}, "PK": {}, "PL": {}, "PT": {}, "RO": {}, "RS": {},
"RU": {}, "SA": {}, "SE": {}, "SG": {}, "SI": {}, "SK": {}, "TH": {}, "TJ": {},
"TM": {}, "TR": {}, "TW": {}, "UA": {}, "US": {}, "UZ": {}, "VN": {}, "ZA": {},
}
+24
View File
@@ -0,0 +1,24 @@
package geoflag
import "testing"
func TestFromText(t *testing.T) {
cases := []struct {
in, cc string
}{
{"DE-Frankfurt-1", "DE"},
{"[NL] Amsterdam", "NL"},
{"🇺🇸 US West", "US"},
{"Germany Hetzner", "DE"},
{"unknown-node", ""},
}
for _, c := range cases {
cc, emoji := FromText(c.in)
if cc != c.cc {
t.Fatalf("%q: got cc %q want %q", c.in, cc, c.cc)
}
if c.cc != "" && emoji == "" {
t.Fatalf("%q: empty emoji", c.in)
}
}
}
+332
View File
@@ -0,0 +1,332 @@
// Package remnawave fetches VPN share links from a Remnawave panel.
//
// Real panel endpoints (API ≈2.x, see remnawave/backend + rw-sdk):
//
// GET {base}/api/sub/{shortUuid} — public subscription body (base64/YAML/text)
// GET {base}/api/sub/{shortUuid}/{clientType} — typed client subscription
// GET {base}/api/subscriptions/by-short-uuid/{shortUuid} — JSON subscription meta (Bearer API token)
// GET {base}/api/subscriptions/by-short-uuid/{shortUuid}/raw — rich raw payload (Bearer)
// GET {base}/api/users/by-short-uuid/{shortUuid} — user incl. subscriptionUrl (Bearer)
//
// Auth: Authorization: Bearer <API token> from panel → API Tokens.
// Optional: X-Api-Key for Caddy auth frontends.
// Responses are often wrapped as {"response": ...}.
package remnawave
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"vpnclient/internal/subscription"
)
// Settings are persisted locally (never committed as secrets).
type Settings struct {
BaseURL string `json:"base_url"`
Token string `json:"token,omitempty"`
ShortUUID string `json:"short_uuid"`
CaddyToken string `json:"caddy_token,omitempty"`
}
// NormalizeBase strips trailing slash and optional trailing /api.
func NormalizeBase(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "/")
if strings.HasSuffix(strings.ToLower(raw), "/api") {
raw = strings.TrimRight(raw[:len(raw)-4], "/")
}
return raw
}
// PublicSubURL is the standard Remnawave client subscription URL.
func PublicSubURL(base, shortUUID string) string {
base = NormalizeBase(base)
shortUUID = strings.TrimSpace(shortUUID)
if base == "" || shortUUID == "" {
return ""
}
return base + "/api/sub/" + url.PathEscape(shortUUID)
}
// ParseInput extracts panel base + short UUID from a Remnawave subscription URL.
// Accepts:
// - https://panel.example.com/api/sub/{short}
// - https://panel.example.com/api/sub/{short}/clash
// - https://sub.example.com/{short} (custom sub page — returns short only if path is one segment)
func ParseInput(raw string) (base, shortUUID string, ok bool) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", "", false
}
path := strings.Trim(u.Path, "/")
parts := strings.Split(path, "/")
// …/api/sub/{short}[/{clientType}]
for i := 0; i+1 < len(parts); i++ {
if strings.EqualFold(parts[i], "api") && strings.EqualFold(parts[i+1], "sub") && i+2 < len(parts) {
short := parts[i+2]
if short == "" || strings.EqualFold(short, "outline") {
return "", "", false
}
base = u.Scheme + "://" + u.Host
if i > 0 {
base += "/" + strings.Join(parts[:i], "/")
}
return NormalizeBase(base), short, true
}
}
return "", "", false
}
// 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) {
s.BaseURL = NormalizeBase(s.BaseURL)
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
s.Token = strings.TrimSpace(s.Token)
s.CaddyToken = strings.TrimSpace(s.CaddyToken)
if s.BaseURL == "" || s.ShortUUID == "" {
return nil, "", fmt.Errorf("укажите URL панели Remnawave и short UUID")
}
if _, err := url.Parse(s.BaseURL); err != nil {
return nil, "", fmt.Errorf("некорректный URL панели")
}
client := &http.Client{Timeout: 45 * time.Second}
pub := PublicSubURL(s.BaseURL, s.ShortUUID)
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
} 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 used == "" {
used = pub
}
return items, used, nil
} else if err != nil {
lastErr = err
}
}
if lastErr != nil {
return nil, "", lastErr
}
return nil, "", fmt.Errorf("Remnawave: нет поддерживаемых ссылок (проверьте short UUID / токен / правила ответа подписки)")
}
func fetchWithToken(ctx context.Context, client *http.Client, s Settings) ([]subscription.Item, string, error) {
base := s.BaseURL
short := s.ShortUUID
// Prefer admin raw endpoint (links in JSON); fall back to meta → subscriptionUrl.
candidates := []string{
base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short) + "/raw",
base + "/api/sub/" + url.PathEscape(short) + "/raw",
base + "/api/subscriptions/by-short-uuid/" + url.PathEscape(short),
base + "/api/users/by-short-uuid/" + url.PathEscape(short),
}
var lastErr error
for _, endpoint := range candidates {
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 subURL := extractSubscriptionURL(body); subURL != "" {
items, err := subscription.Fetch(ctx, subURL)
if err == nil && len(items) > 0 {
return items, subURL, nil
}
if err != nil {
lastErr = err
}
}
}
if lastErr != nil {
return nil, "", lastErr
}
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)
if err != nil {
return nil, err
}
return parseRemnawaveBody(body)
}
func doGET(ctx context.Context, client *http.Client, s Settings, endpoint string, auth bool) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Navis/3.9 (Remnawave)")
req.Header.Set("Accept", "*/*")
if auth && s.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.Token)
}
if s.CaddyToken != "" {
req.Header.Set("X-Api-Key", s.CaddyToken)
}
// Remnawave proxy-check middleware may reset plain HTTP / direct panel access
// without forwarded headers (see remnawave proxy-check.middleware).
if u, err := url.Parse(endpoint); err == nil && shouldSendProxyHeaders(u) {
req.Header.Set("X-Forwarded-Proto", u.Scheme)
req.Header.Set("X-Forwarded-For", "127.0.0.1")
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("Remnawave запрос: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return "", 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 string(raw), nil
}
func shouldSendProxyHeaders(u *url.URL) bool {
if u == nil {
return false
}
if strings.EqualFold(u.Scheme, "http") {
return true
}
host := u.Hostname()
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
ip := net.ParseIP(host)
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}
func parseRemnawaveBody(body string) ([]subscription.Item, 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
}
// 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
}
}
}
return subscription.ParseBody(body)
}
func flattenJSONLinks(body string) string {
links := extractLinkStrings(body)
if links != "" {
return links
}
return body
}
func extractLinkStrings(body string) string {
var root any
if err := json.Unmarshal([]byte(body), &root); err != nil {
return ""
}
var lines []string
walkJSON(root, &lines)
return strings.Join(lines, "\n")
}
func walkJSON(v any, out *[]string) {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
lower := strings.ToLower(s)
if strings.Contains(lower, "://") || strings.HasPrefix(lower, "ss://") {
*out = append(*out, s)
}
case []any:
for _, el := range t {
walkJSON(el, out)
}
case map[string]any:
for _, el := range t {
walkJSON(el, out)
}
}
}
func extractSubscriptionURL(body string) string {
var root any
if err := json.Unmarshal([]byte(body), &root); err != nil {
return ""
}
// Unwrap {"response": ...}
if m, ok := root.(map[string]any); ok {
if resp, ok := m["response"]; ok {
root = resp
}
}
return findURLField(root, []string{
"subscriptionUrl", "subscription_url", "subscriptionURL", "url",
})
}
func findURLField(v any, keys []string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
for mk, mv := range t {
if strings.EqualFold(mk, k) {
if s, ok := mv.(string); ok {
s = strings.TrimSpace(s)
if strings.HasPrefix(strings.ToLower(s), "http") {
return s
}
}
}
}
}
for _, mv := range t {
if u := findURLField(mv, keys); u != "" {
return u
}
}
case []any:
for _, el := range t {
if u := findURLField(el, keys); u != "" {
return u
}
}
}
return ""
}
+30
View File
@@ -0,0 +1,30 @@
package remnawave
import "testing"
func TestPublicSubURL(t *testing.T) {
got := PublicSubURL("https://panel.example.com/api/", "abc12")
want := "https://panel.example.com/api/sub/abc12"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestParseInput(t *testing.T) {
base, short, ok := ParseInput("https://vpn.example.com/api/sub/xyz789/clash")
if !ok || base != "https://vpn.example.com" || short != "xyz789" {
t.Fatalf("got base=%q short=%q ok=%v", base, short, ok)
}
_, _, ok = ParseInput("https://example.com/not-a-sub")
if ok {
t.Fatal("expected not ok")
}
}
func TestExtractSubscriptionURL(t *testing.T) {
body := `{"response":{"subscriptionUrl":"https://vpn.example.com/api/sub/abc"}}`
got := extractSubscriptionURL(body)
if got != "https://vpn.example.com/api/sub/abc" {
t.Fatalf("got %q", got)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "3.8.2"
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.