Release 1.3.0: Hysteria2 BBR/Salamander/masquerade options and subscription URL import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-28 07:26:17 +03:00
co-authored by Cursor
parent 7eaf53f7c2
commit 5d2cb6d76a
18 changed files with 807 additions and 52 deletions
+16
View File
@@ -54,6 +54,8 @@ type uiState struct {
Version string `json:"version"` Version string `json:"version"`
Update update.Status `json:"update"` Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"` Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
} }
func main() { func main() {
@@ -133,6 +135,8 @@ func main() {
mustBind(w, "pingServers", a.pingServers) mustBind(w, "pingServers", a.pingServers)
mustBind(w, "checkUpdate", a.checkUpdate) mustBind(w, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate) mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "saveHy2", a.saveHy2)
mustBind(w, "importSubscription", a.importSubscription)
go a.autoCheckUpdate() go a.autoCheckUpdate()
@@ -190,6 +194,8 @@ func (a *app) getState() (uiState, error) {
Version: update.CurrentVersion, Version: update.CurrentVersion,
Update: a.updateStatus, Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...), Pings: append([]netcheck.Result(nil), a.pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.mgr.ActiveHy2Options(),
} }
if out.Protocol == "" { if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil { if p, err := cfg.ActiveProfile(); err == nil {
@@ -274,6 +280,16 @@ func (a *app) installCore() (string, error) {
return strings.Join(parts, " | "), nil return strings.Join(parts, " | "), nil
} }
func (a *app) saveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SaveHy2Options(opts)
}
func (a *app) importSubscription(rawURL string) (int, error) {
return a.mgr.ImportSubscription(rawURL)
}
func (a *app) pingServers() ([]netcheck.Result, error) { func (a *app) pingServers() ([]netcheck.Result, error) {
a.mu.Lock() a.mu.Lock()
list := a.mgr.Profiles() list := a.mgr.Profiles()
+15
View File
@@ -2,6 +2,7 @@
"active": "naive-main", "active": "naive-main",
"system_proxy": true, "system_proxy": true,
"bin_dir": "bin", "bin_dir": "bin",
"subscription_url": "",
"profiles": [ "profiles": [
{ {
"name": "naive-main", "name": "naive-main",
@@ -11,6 +12,20 @@
"socks://127.0.0.1:1080", "socks://127.0.0.1:1080",
"http://127.0.0.1:1081" "http://127.0.0.1:1081"
] ]
},
{
"name": "hy2-example",
"protocol": "hysteria2",
"proxy": "",
"hy2_congestion": "bbr",
"hy2_bbr_profile": "standard",
"hy2_obfs": "salamander",
"hy2_obfs_password": "",
"hy2_sni": "",
"listen": [
"socks://127.0.0.1:1080",
"http://127.0.0.1:1081"
]
} }
] ]
} }
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.2.0", "version": "1.3.0",
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
"mandatory": false "mandatory": false
} }
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.2.0", "version": "1.3.0",
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
"mandatory": false "mandatory": false
} }
+157 -4
View File
@@ -112,6 +112,32 @@
.stack { display: grid; gap: 10px; margin-bottom: 14px; } .stack { display: grid; gap: 10px; margin-bottom: 14px; }
details.hy2 {
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 10px 12px;
background: rgba(255,255,255,.55);
}
details.hy2 summary {
cursor: pointer;
font-weight: 700;
font-size: .84rem;
color: var(--accent);
list-style: none;
}
details.hy2 .grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
details.hy2 label.field { margin-top: 6px; }
.check {
display: flex; align-items: center; gap: 8px;
font-size: .88rem; margin-top: 8px;
}
.row { .row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 12px 14px; gap: 12px; padding: 12px 14px;
@@ -299,7 +325,70 @@
<label class="field" for="proxy">Ссылка сервера</label> <label class="field" for="proxy">Ссылка сервера</label>
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" /> <input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
</div> </div>
<div>
<label class="field" for="subUrl">URL подписки</label>
<div class="profile-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<button class="icon-btn" id="subBtn" type="button" title="Обновить подписку" style="width:auto;padding:0 14px;font-size:.8rem"></button>
</div> </div>
</div>
</div>
<details class="hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / Salamander / маскировка</summary>
<div class="grid2">
<div>
<label class="field" for="hy2Congestion">Congestion</label>
<select id="hy2Congestion">
<option value="bbr">BBR</option>
<option value="reno">Reno</option>
</select>
</div>
<div>
<label class="field" for="hy2Bbr">BBR profile</label>
<select id="hy2Bbr">
<option value="standard">standard</option>
<option value="conservative">conservative</option>
<option value="aggressive">aggressive</option>
</select>
</div>
<div>
<label class="field" for="hy2Up">Brutal up</label>
<input id="hy2Up" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Down">Brutal down</label>
<input id="hy2Down" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Obfs">Obfs</label>
<select id="hy2Obfs">
<option value="">нет</option>
<option value="salamander">salamander</option>
<option value="gecko">gecko</option>
</select>
</div>
<div>
<label class="field" for="hy2ObfsPass">Obfs password</label>
<input id="hy2ObfsPass" type="text" placeholder="пароль salamander/gecko" />
</div>
<div>
<label class="field" for="hy2Sni">SNI (маскарад HTTPS)</label>
<input id="hy2Sni" type="text" placeholder="cdn.example.com" />
</div>
<div>
<label class="field" for="hy2Pin">pinSHA256</label>
<input id="hy2Pin" type="text" placeholder="опционально" />
</div>
<div>
<label class="field" for="hy2Hop">Hop interval</label>
<input id="hy2Hop" type="text" placeholder="30s" />
</div>
</div>
<label class="check"><input id="hy2Insecure" type="checkbox" /> TLS insecure</label>
<label class="check"><input id="hy2Fast" type="checkbox" /> Fast Open</label>
<label class="check"><input id="hy2Lazy" type="checkbox" /> Lazy connect</label>
</details>
<div class="row"> <div class="row">
<span>Системный прокси Windows</span> <span>Системный прокси Windows</span>
@@ -373,8 +462,51 @@
const updateBtn = $("updateBtn"); const updateBtn = $("updateBtn");
const pingList = $("pingList"); const pingList = $("pingList");
const verLabel = $("verLabel"); const verLabel = $("verLabel");
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const SHOP_URL = "https://evilfox.win/"; const SHOP_URL = "https://evilfox.win/";
function readHy2() {
return {
congestion: $("hy2Congestion").value,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = proxy.value.trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false; let busy = false;
let connected = false; let connected = false;
let formHydrated = false; let formHydrated = false;
@@ -382,10 +514,15 @@
let profiles = []; let profiles = [];
function markDirty() { dirty = true; } function markDirty() { dirty = true; }
[proxy, nameInput, sysproxy].forEach((el) => { [
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty); el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty); el.addEventListener("change", markDirty);
el.addEventListener("focus", markDirty);
}); });
function setMeta(text, kind) { function setMeta(text, kind) {
@@ -465,12 +602,16 @@
fillProfiles(state.profiles || [], state.active_profile || state.profile); fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderPings(state.pings || []); renderPings(state.pings || []);
renderUpdate(state.update, state.version); renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) { if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {}; const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || ""; nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || ""); proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy; if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
fillHy2(state.hy2);
formHydrated = true; formHydrated = true;
if (syncForm) dirty = false; if (syncForm) dirty = false;
} }
@@ -498,7 +639,7 @@
async function withBusy(fn) { async function withBusy(fn) {
if (busy) return; if (busy) return;
busy = true; busy = true;
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = true; }); [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = true; });
try { try {
await fn(); await fn();
} finally { } finally {
@@ -507,7 +648,7 @@
await refresh({ syncForm: true }); await refresh({ syncForm: true });
} catch (e) { } catch (e) {
setMeta(String(e), "err"); setMeta(String(e), "err");
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = false; }); [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn].forEach((b) => { if (b) b.disabled = false; });
} }
} }
} }
@@ -527,6 +668,7 @@
} else { } else {
setMeta("Подключение…"); setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked); await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
await connect(); await connect();
setMeta("Туннель активен", "ok"); setMeta("Туннель активен", "ok");
} }
@@ -536,6 +678,7 @@
saveBtn.addEventListener("click", () => withBusy(async () => { saveBtn.addEventListener("click", () => withBusy(async () => {
try { try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked); await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok"); setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); } } catch (e) { setMeta(String(e), "err"); }
})); }));
@@ -589,6 +732,16 @@
shopBtn.addEventListener("click", openShop); shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop); shopLink.addEventListener("click", openShop);
subBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Загрузка подписки…");
const n = await importSubscription(subUrl.value.trim());
formHydrated = false;
dirty = false;
setMeta("Импортировано профилей: " + n, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
pingBtn.addEventListener("click", () => withBusy(async () => { pingBtn.addEventListener("click", () => withBusy(async () => {
try { try {
setMeta("Проверка серверов…"); setMeta("Проверка серверов…");
+20 -1
View File
@@ -23,9 +23,12 @@ type Config struct {
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect. // SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
SystemProxy bool `json:"system_proxy"` SystemProxy bool `json:"system_proxy"`
// BinDir is where protocol binaries (naive.exe) are stored. // BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
BinDir string `json:"bin_dir,omitempty"` BinDir string `json:"bin_dir,omitempty"`
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
Profiles []Profile `json:"profiles"` Profiles []Profile `json:"profiles"`
} }
@@ -47,6 +50,22 @@ type Profile struct {
NoPostQuantum bool `json:"no_post_quantum,omitempty"` NoPostQuantum bool `json:"no_post_quantum,omitempty"`
Log string `json:"log,omitempty"` Log string `json:"log,omitempty"`
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
// Hysteria 2 options (client-side). Masquerade on server is separate;
// here SNI/insecure/pin emulate HTTPS look + salamander/gecko obfuscation.
Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno
Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive
Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal
Hy2BandwidthDown string `json:"hy2_bandwidth_down,omitempty"`
Hy2Obfs string `json:"hy2_obfs,omitempty"` // salamander | gecko
Hy2ObfsPassword string `json:"hy2_obfs_password,omitempty"`
Hy2SNI string `json:"hy2_sni,omitempty"`
Hy2Insecure bool `json:"hy2_insecure,omitempty"`
Hy2PinSHA256 string `json:"hy2_pin_sha256,omitempty"`
Hy2FastOpen bool `json:"hy2_fast_open,omitempty"`
Hy2Lazy bool `json:"hy2_lazy,omitempty"`
Hy2HopInterval string `json:"hy2_hop_interval,omitempty"` // e.g. 30s
Hy2ALPN string `json:"hy2_alpn,omitempty"`
} }
// DefaultListen returns listen addresses suitable for Windows system proxy. // DefaultListen returns listen addresses suitable for Windows system proxy.
-4
View File
@@ -80,11 +80,7 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
for i := range c.Profiles { for i := range c.Profiles {
if c.Profiles[i].Name == name { if c.Profiles[i].Name == name {
c.Profiles[i].Proxy = proxy c.Profiles[i].Proxy = proxy
if proto != "" {
c.Profiles[i].Protocol = proto c.Profiles[i].Protocol = proto
} else if c.Profiles[i].Protocol == "" {
c.Profiles[i].Protocol = ProtocolNaive
}
if len(c.Profiles[i].Listen) == 0 { if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen() c.Profiles[i].Listen = defaultListen()
} }
+111
View File
@@ -16,6 +16,7 @@ import (
"vpnclient/internal/linknorm" "vpnclient/internal/linknorm"
"vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive" "vpnclient/internal/protocols/naive"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy" "vpnclient/internal/sysproxy"
) )
@@ -250,6 +251,9 @@ func (m *Manager) SaveProfile(name, proxy string) error {
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
return err return err
} }
if p, err := m.cfg.ActiveProfile(); err == nil {
hysteria2.EnrichProfile(p)
}
return config.Save(m.cfgPath, *m.cfg) return config.Save(m.cfgPath, *m.cfg)
} }
@@ -299,6 +303,9 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error {
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil { if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
return err return err
} }
if p, err := m.cfg.ActiveProfile(); err == nil {
hysteria2.EnrichProfile(p)
}
return config.Save(m.cfgPath, *m.cfg) return config.Save(m.cfgPath, *m.cfg)
} }
@@ -320,6 +327,110 @@ func (m *Manager) Profiles() []config.ProfileInfo {
return m.cfg.ListProfiles() return m.cfg.ListProfiles()
} }
// Hy2Options are editable Hysteria 2 extras for the active profile.
type Hy2Options struct {
Congestion string `json:"congestion"`
BBRProfile string `json:"bbr_profile"`
BandwidthUp string `json:"bandwidth_up"`
BandwidthDown string `json:"bandwidth_down"`
Obfs string `json:"obfs"`
ObfsPassword string `json:"obfs_password"`
SNI string `json:"sni"`
Insecure bool `json:"insecure"`
PinSHA256 string `json:"pin_sha256"`
FastOpen bool `json:"fast_open"`
Lazy bool `json:"lazy"`
HopInterval string `json:"hop_interval"`
}
func (m *Manager) ActiveHy2Options() Hy2Options {
m.mu.Lock()
defer m.mu.Unlock()
p, err := m.cfg.ActiveProfile()
if err != nil {
return Hy2Options{Congestion: "bbr", BBRProfile: "standard"}
}
return Hy2Options{
Congestion: p.Hy2Congestion,
BBRProfile: p.Hy2BBRProfile,
BandwidthUp: p.Hy2BandwidthUp,
BandwidthDown: p.Hy2BandwidthDown,
Obfs: p.Hy2Obfs,
ObfsPassword: p.Hy2ObfsPassword,
SNI: p.Hy2SNI,
Insecure: p.Hy2Insecure,
PinSHA256: p.Hy2PinSHA256,
FastOpen: p.Hy2FastOpen,
Lazy: p.Hy2Lazy,
HopInterval: p.Hy2HopInterval,
}
}
func (m *Manager) SaveHy2Options(opts Hy2Options) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return err
}
p.Protocol = config.ProtocolHysteria2
p.Hy2Congestion = strings.TrimSpace(opts.Congestion)
p.Hy2BBRProfile = strings.TrimSpace(opts.BBRProfile)
p.Hy2BandwidthUp = strings.TrimSpace(opts.BandwidthUp)
p.Hy2BandwidthDown = strings.TrimSpace(opts.BandwidthDown)
p.Hy2Obfs = strings.TrimSpace(opts.Obfs)
p.Hy2ObfsPassword = strings.TrimSpace(opts.ObfsPassword)
p.Hy2SNI = strings.TrimSpace(opts.SNI)
p.Hy2Insecure = opts.Insecure
p.Hy2PinSHA256 = strings.TrimSpace(opts.PinSHA256)
p.Hy2FastOpen = opts.FastOpen
p.Hy2Lazy = opts.Lazy
p.Hy2HopInterval = strings.TrimSpace(opts.HopInterval)
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) SubscriptionURL() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.SubscriptionURL
}
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
m.mu.Lock()
if m.engine != nil && m.engine.Running() {
m.mu.Unlock()
return 0, fmt.Errorf("сначала отключитесь")
}
m.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
items, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return 0, err
}
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
}
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(items), nil
}
func (m *Manager) SaveConfig() error { func (m *Manager) SaveConfig() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -0,0 +1,141 @@
package hysteria2
import (
"fmt"
"os"
"strings"
"vpnclient/internal/config"
)
func writeRuntimeConfig(path string, profile config.Profile) error {
ep, err := ParseEndpoint(profile.Proxy)
if err != nil {
return err
}
var insecureOverride *bool
if profile.Hy2Insecure {
v := true
insecureOverride = &v
}
ep.ApplyOverrides(profile.Hy2SNI, profile.Hy2Obfs, profile.Hy2ObfsPassword, profile.Hy2PinSHA256, insecureOverride)
socks := "127.0.0.1:1080"
httpListen := "127.0.0.1:1081"
if hp, ok := profile.SOCKSListenHostPort(); ok {
socks = hp
}
if hp, ok := profile.HTTPListenHostPort(); ok {
httpListen = hp
}
var b strings.Builder
b.WriteString("# generated by Navis — do not edit\n")
// Expanded form so congestion/obfs/tls can coexist with auth.
b.WriteString("server: " + yamlQuote(ep.Server) + "\n")
if ep.Auth != "" {
b.WriteString("auth: " + yamlQuote(ep.Auth) + "\n")
}
if ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" {
b.WriteString("tls:\n")
if ep.SNI != "" {
b.WriteString(" sni: " + yamlQuote(ep.SNI) + "\n")
}
if ep.Insecure {
b.WriteString(" insecure: true\n")
}
if ep.PinSHA256 != "" {
b.WriteString(" pinSHA256: " + yamlQuote(ep.PinSHA256) + "\n")
}
}
obfs := strings.ToLower(strings.TrimSpace(ep.Obfs))
if obfs == "" {
obfs = strings.ToLower(strings.TrimSpace(profile.Hy2Obfs))
}
obfsPass := ep.ObfsPassword
if obfsPass == "" {
obfsPass = profile.Hy2ObfsPassword
}
switch obfs {
case "salamander":
if obfsPass == "" {
return fmt.Errorf("hysteria2: salamander требует пароль (obfs-password)")
}
b.WriteString("obfs:\n")
b.WriteString(" type: salamander\n")
b.WriteString(" salamander:\n")
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
case "gecko":
if obfsPass == "" {
return fmt.Errorf("hysteria2: gecko требует пароль (obfs-password)")
}
b.WriteString("obfs:\n")
b.WriteString(" type: gecko\n")
b.WriteString(" gecko:\n")
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
}
// Congestion: BBR (default) or reno. Bandwidth enables Brutal instead.
up := ParseBandwidthMbps(profile.Hy2BandwidthUp)
down := ParseBandwidthMbps(profile.Hy2BandwidthDown)
cong := strings.ToLower(strings.TrimSpace(profile.Hy2Congestion))
if cong == "" {
cong = "bbr"
}
bbrProfile := strings.ToLower(strings.TrimSpace(profile.Hy2BBRProfile))
if bbrProfile == "" {
bbrProfile = "standard"
}
if up != "" || down != "" {
b.WriteString("bandwidth:\n")
if up != "" {
b.WriteString(" up: " + yamlQuote(up) + "\n")
}
if down != "" {
b.WriteString(" down: " + yamlQuote(down) + "\n")
}
} else {
// No Brutal → use BBR/reno
b.WriteString("congestion:\n")
b.WriteString(" type: " + yamlQuote(cong) + "\n")
if cong == "bbr" {
b.WriteString(" bbrProfile: " + yamlQuote(bbrProfile) + "\n")
}
}
if hop := strings.TrimSpace(profile.Hy2HopInterval); hop != "" {
b.WriteString("transport:\n")
b.WriteString(" type: udp\n")
b.WriteString(" udp:\n")
b.WriteString(" hopInterval: " + yamlQuote(hop) + "\n")
}
if profile.Hy2FastOpen {
b.WriteString("fastOpen: true\n")
}
if profile.Hy2Lazy {
b.WriteString("lazy: true\n")
}
// "Masquerade extras" on client = look like normal HTTPS via SNI already above;
// optional ALPN hint kept for compatibility with some panels.
if alpn := strings.TrimSpace(profile.Hy2ALPN); alpn != "" {
// Official client uses TLS defaults; keep as comment for operators.
b.WriteString("# alpn-hint: " + yamlQuote(alpn) + "\n")
}
b.WriteString("socks5:\n")
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
b.WriteString("http:\n")
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
return writeFile(path, b.String())
}
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
+115
View File
@@ -0,0 +1,115 @@
package hysteria2
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// Endpoint is a parsed Hysteria 2 share link plus optional overrides.
type Endpoint struct {
Server string // host:port or multi-port host
Auth string
SNI string
Insecure bool
PinSHA256 string
Obfs string // salamander | gecko
ObfsPassword string
Remark string
RawURI string
}
// ParseEndpoint parses hysteria2:// / hy2:// into discrete fields.
func ParseEndpoint(raw string) (Endpoint, error) {
normalized, remark, err := NormalizeShareLink(raw)
if err != nil {
return Endpoint{}, err
}
u, err := url.Parse(normalized)
if err != nil {
return Endpoint{}, err
}
ep := Endpoint{Remark: remark, RawURI: normalized}
if u.User != nil {
user := u.User.Username()
if pass, ok := u.User.Password(); ok {
ep.Auth = user + ":" + pass
} else {
ep.Auth, _ = url.QueryUnescape(user)
}
}
ep.Server = u.Host
if ep.Server == "" {
return Endpoint{}, fmt.Errorf("hysteria2 URI missing host")
}
q := u.Query()
ep.SNI = first(q, "sni", "peer")
ep.PinSHA256 = first(q, "pinSHA256", "pinsha256")
ep.Obfs = first(q, "obfs")
ep.ObfsPassword = first(q, "obfs-password", "obfs_password")
if v := first(q, "insecure"); v == "1" || strings.EqualFold(v, "true") {
ep.Insecure = true
}
return ep, nil
}
func first(q url.Values, keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(q.Get(k)); v != "" {
return v
}
}
return ""
}
// ApplyOverrides merges non-empty profile overrides into the endpoint.
func (ep *Endpoint) ApplyOverrides(sni, obfs, obfsPass, pin string, insecure *bool) {
if sni != "" {
ep.SNI = sni
}
if obfs != "" {
ep.Obfs = obfs
}
if obfsPass != "" {
ep.ObfsPassword = obfsPass
}
if pin != "" {
ep.PinSHA256 = pin
}
if insecure != nil {
ep.Insecure = *insecure
}
}
// BoolPtr helper for optional bool overrides.
func BoolPtr(v bool) *bool { return &v }
// HasTLSExtras reports whether discrete TLS/obfs fields should be written.
func (ep Endpoint) HasTLSExtras() bool {
return ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" || ep.Obfs != ""
}
// PortHint returns a single port for ping.
func (ep Endpoint) PortHint() string {
_, port, err := HostPort(ep.RawURI)
if err != nil || port == "" {
return "443"
}
return port
}
// ParseBandwidthMbps tries to normalize user input like "100" or "100 mbps".
func ParseBandwidthMbps(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" || raw == "0" || raw == "auto" || raw == "bbr" {
return ""
}
if strings.ContainsAny(raw, "bkmgt") {
return raw
}
if _, err := strconv.Atoi(raw); err == nil {
return raw + " mbps"
}
return raw
}
-25
View File
@@ -180,32 +180,7 @@ func (e *Engine) LocalSOCKSProxy() (string, bool) {
return e.profile.SOCKSListenHostPort() return e.profile.SOCKSListenHostPort()
} }
func writeRuntimeConfig(path string, profile config.Profile) error {
server, _, err := NormalizeShareLink(profile.Proxy)
if err != nil {
return err
}
socks := "127.0.0.1:1080"
httpListen := "127.0.0.1:1081"
if hp, ok := profile.SOCKSListenHostPort(); ok {
socks = hp
}
if hp, ok := profile.HTTPListenHostPort(); ok {
httpListen = hp
}
var b strings.Builder
b.WriteString("# generated by Navis — do not edit\n")
b.WriteString("server: " + yamlQuote(server) + "\n")
b.WriteString("socks5:\n")
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
b.WriteString("http:\n")
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
return os.WriteFile(path, []byte(b.String()), 0o600)
}
func yamlQuote(s string) string { func yamlQuote(s string) string {
// Always quote — URIs and host:port can confuse YAML.
escaped := strings.ReplaceAll(s, `\`, `\\`) escaped := strings.ReplaceAll(s, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`) escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"` return `"` + escaped + `"`
+35
View File
@@ -0,0 +1,35 @@
package hysteria2
import "vpnclient/internal/config"
// EnrichProfile fills empty hy2 option fields from the share URI.
func EnrichProfile(p *config.Profile) {
if p == nil || p.Protocol != config.ProtocolHysteria2 || p.Proxy == "" {
return
}
ep, err := ParseEndpoint(p.Proxy)
if err != nil {
return
}
if p.Hy2SNI == "" {
p.Hy2SNI = ep.SNI
}
if p.Hy2Obfs == "" {
p.Hy2Obfs = ep.Obfs
}
if p.Hy2ObfsPassword == "" {
p.Hy2ObfsPassword = ep.ObfsPassword
}
if p.Hy2PinSHA256 == "" {
p.Hy2PinSHA256 = ep.PinSHA256
}
if ep.Insecure {
p.Hy2Insecure = true
}
if p.Hy2Congestion == "" {
p.Hy2Congestion = "bbr"
}
if p.Hy2BBRProfile == "" {
p.Hy2BBRProfile = "standard"
}
}
+162
View File
@@ -0,0 +1,162 @@
package subscription
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
)
// Item is one imported share link.
type Item struct {
Name string
Protocol config.Protocol
URI string
}
// Fetch downloads a subscription body and parses proxy share links.
func Fetch(ctx context.Context, rawURL string) ([]Item, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("пустой URL подписки")
}
u, err := url.Parse(rawURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return nil, fmt.Errorf("нужен http(s) URL подписки")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Navis/1.3.0")
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("подписка HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
return ParseBody(string(body))
}
// ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription).
func ParseBody(body string) ([]Item, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("пустая подписка")
}
text := body
if !looksLikeLinks(body) {
if decoded, err := decodeMaybeBase64(body); err == nil && looksLikeLinks(decoded) {
text = decoded
}
}
lines := splitLines(text)
out := make([]Item, 0, len(lines))
seen := map[string]int{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Some panels wrap links in quotes
line = strings.Trim(line, `"'`)
proto := config.DetectProtocol(line)
if proto == "" {
continue
}
normalized, detected, remark, err := linknorm.Normalize(proto, line)
if err != nil {
continue
}
name := remark
if name == "" {
name = defaultName(detected, normalized)
}
base := name
for n := 2; ; n++ {
if _, ok := seen[name]; !ok {
break
}
name = fmt.Sprintf("%s-%d", base, n)
}
seen[name] = 1
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
}
if len(out) == 0 {
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
}
return out, nil
}
func looksLikeLinks(s string) bool {
lower := strings.ToLower(s)
return strings.Contains(lower, "hysteria2://") ||
strings.Contains(lower, "hy2://") ||
strings.Contains(lower, "naive+") ||
strings.Contains(lower, "https://") ||
strings.Contains(lower, "quic://")
}
func decodeMaybeBase64(s string) (string, error) {
s = strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, s)
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
raw, err = base64.RawStdEncoding.DecodeString(s)
}
if err != nil {
raw, err = base64.URLEncoding.DecodeString(s)
}
if err != nil {
return "", err
}
return string(raw), nil
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.Split(s, "\n")
}
func defaultName(proto config.Protocol, uri string) string {
host := ""
switch proto {
case config.ProtocolHysteria2:
if h, _, err := hysteria2.HostPort(uri); err == nil {
host = h
}
default:
if u, _, err := naive.ParseShareLink(uri); err == nil {
if parsed, err := url.Parse(u); err == nil {
host = parsed.Hostname()
}
}
}
if host == "" {
host = string(proto)
}
return string(proto) + "-" + host
}
+17
View File
@@ -0,0 +1,17 @@
package subscription
import "testing"
func TestParseBodyPlain(t *testing.T) {
body := `# comment
hysteria2://pass@ex.com:443/?sni=ex.com&obfs=salamander&obfs-password=x#EU
naive+https://u:p@n.example.com#naive1
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items", len(items))
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
) )
// CurrentVersion is the shipped client version. // CurrentVersion is the shipped client version.
const CurrentVersion = "1.2.0" const CurrentVersion = "1.3.0"
// DefaultManifestURL is the update feed (hosted in the project git repo). // DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"version": "1.2.0", "version": "1.3.0",
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)", "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25", "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063",
"mandatory": false "mandatory": false
} }
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"FixedFileInfo": { "FixedFileInfo": {
"FileVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 }, "FileVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 },
"ProductVersion": { "Major": 1, "Minor": 2, "Patch": 0, "Build": 0 }, "ProductVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
"FileOS": "40004", "FileOS": "40004",
@@ -11,11 +11,11 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "Navis", "CompanyName": "Navis",
"FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows", "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows",
"FileVersion": "1.2.0.0", "FileVersion": "1.3.0.0",
"InternalName": "Navis", "InternalName": "Navis",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "1.2.0.0" "ProductVersion": "1.3.0.0"
}, },
"VarFileInfo": { "VarFileInfo": {
"Translation": { "Translation": {