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:
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
+4
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+4
-4
@@ -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
@@ -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,8 +325,71 @@
|
|||||||
<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>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
@@ -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("Проверка серверов…");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 + `"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user