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:
@@ -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
@@ -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("Пинг серверов…");
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user