Initial Navis client with NaiveProxy, profiles, ping and git updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package appui
|
||||
|
||||
import _ "embed"
|
||||
|
||||
|
||||
//go:embed index.html
|
||||
var IndexHTML string
|
||||
@@ -0,0 +1,625 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Navis</title>
|
||||
<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=Syne:wght@600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #dff3ea;
|
||||
--bg1: #f3faf7;
|
||||
--ink: #10241c;
|
||||
--muted: #567064;
|
||||
--line: rgba(16, 36, 28, 0.12);
|
||||
--accent: #0c7a55;
|
||||
--accent-2: #10956a;
|
||||
--danger: #b42318;
|
||||
--surface: rgba(255, 255, 255, 0.78);
|
||||
--shadow: 0 22px 60px rgba(16, 36, 28, 0.10);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; min-height: 100%;
|
||||
font-family: Manrope, sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(900px 520px at 0% 0%, #bfe8d4 0%, transparent 55%),
|
||||
radial-gradient(700px 420px at 100% 10%, #c9dcf0 0%, transparent 50%),
|
||||
linear-gradient(160deg, var(--bg0), var(--bg1));
|
||||
}
|
||||
body { display: grid; place-items: center; padding: 22px 14px; }
|
||||
.shell {
|
||||
width: min(460px, 100%);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 28px;
|
||||
padding: 24px 22px 20px;
|
||||
animation: rise .5s cubic-bezier(.2,.8,.2,1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.top {
|
||||
display: flex; align-items: center; gap: 14px; margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 48px; height: 48px; border-radius: 14px;
|
||||
background: linear-gradient(145deg, #0c7a55, #1aa876);
|
||||
display: grid; place-items: center;
|
||||
box-shadow: 0 10px 24px rgba(12, 122, 85, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.logo svg { width: 26px; height: 26px; }
|
||||
.brand-wrap { min-width: 0; }
|
||||
.brand {
|
||||
font-family: Syne, sans-serif;
|
||||
font-size: 1.85rem; font-weight: 800;
|
||||
letter-spacing: -0.04em; line-height: 1; margin: 0;
|
||||
}
|
||||
.tagline { margin: 4px 0 0; color: var(--muted); font-size: .86rem; }
|
||||
|
||||
.status {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 16px; font-weight: 600;
|
||||
}
|
||||
.dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #9aa89f;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.8s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(12,122,85,.35); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(12,122,85,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(12,122,85,0); }
|
||||
}
|
||||
|
||||
label.field {
|
||||
display: block; font-size: .72rem; font-weight: 700;
|
||||
letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 0 0 8px;
|
||||
}
|
||||
.profile-bar {
|
||||
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
select, input[type="text"] {
|
||||
width: 100%; border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.92);
|
||||
border-radius: 14px; padding: 12px 14px;
|
||||
font: inherit; color: var(--ink); outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
select:focus, input[type="text"]:focus {
|
||||
border-color: rgba(12,122,85,.55);
|
||||
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 46px; padding: 0; display: grid; place-items: center;
|
||||
border-radius: 14px; border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.7); color: var(--ink);
|
||||
font-size: 1.2rem; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { background: #fff; }
|
||||
.icon-btn.danger { color: var(--danger); }
|
||||
|
||||
.stack { display: grid; gap: 10px; margin-bottom: 14px; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 12px 14px;
|
||||
border: 1px solid var(--line); border-radius: 14px;
|
||||
background: rgba(255,255,255,.55); margin-bottom: 16px;
|
||||
}
|
||||
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute; inset: 0; background: #c5d2cb;
|
||||
border-radius: 999px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.slider::before {
|
||||
content: ""; position: absolute; width: 20px; height: 20px;
|
||||
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.switch input:checked + .slider { background: var(--accent); }
|
||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||
|
||||
.actions { display: grid; gap: 8px; }
|
||||
button.action {
|
||||
appearance: none; border: 0; border-radius: 14px;
|
||||
padding: 13px 16px; font: inherit; font-weight: 700; cursor: pointer;
|
||||
transition: transform .12s, background .15s, opacity .15s;
|
||||
}
|
||||
button.action:active { transform: translateY(1px); }
|
||||
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
.primary { background: var(--accent); color: #f4fff9; }
|
||||
.primary:hover:not(:disabled) { background: var(--accent-2); }
|
||||
.primary.danger { background: var(--danger); }
|
||||
.ghost {
|
||||
background: transparent; color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.ghost:hover:not(:disabled) { background: rgba(255,255,255,.75); color: var(--ink); }
|
||||
|
||||
.meta {
|
||||
margin-top: 14px; font-size: .82rem; color: var(--muted);
|
||||
line-height: 1.45; min-height: 2.6em;
|
||||
}
|
||||
|
||||
.shop {
|
||||
margin-top: 16px;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(12, 122, 85, 0.18);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(12, 122, 85, 0.10), rgba(255,255,255,0.55));
|
||||
}
|
||||
.shop h3 {
|
||||
font-family: Syne, sans-serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.shop p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.shop .action {
|
||||
width: 100%;
|
||||
}
|
||||
.shop-link {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
font-size: .78rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.shop-link:hover { text-decoration: underline; }
|
||||
|
||||
.tools {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ping-list {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.ping-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.55);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.ping-item .ms { font-weight: 700; }
|
||||
.ping-item .ms.ok { color: var(--accent); }
|
||||
.ping-item .ms.bad { color: var(--danger); }
|
||||
|
||||
.update-banner {
|
||||
display: none;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(12,122,85,.25);
|
||||
background: linear-gradient(135deg, rgba(12,122,85,.14), rgba(255,255,255,.7));
|
||||
}
|
||||
.update-banner.show { display: block; }
|
||||
.update-banner strong {
|
||||
font-family: Syne, sans-serif;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.update-banner p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.ver {
|
||||
margin-top: 10px;
|
||||
font-size: .75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(16,36,28,.35);
|
||||
display: none; place-items: center; padding: 18px; z-index: 20;
|
||||
}
|
||||
.modal-backdrop.open { display: grid; }
|
||||
.modal {
|
||||
width: min(400px, 100%); background: #f7fcf9;
|
||||
border-radius: 22px; border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow); padding: 20px;
|
||||
animation: rise .35s ease both;
|
||||
}
|
||||
.modal h2 {
|
||||
font-family: Syne, sans-serif; margin: 0 0 14px;
|
||||
font-size: 1.25rem; letter-spacing: -.02em;
|
||||
}
|
||||
.modal .actions { margin-top: 14px; grid-template-columns: 1fr 1fr; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<strong id="updateTitle">Доступно обновление</strong>
|
||||
<p id="updateNotes"></p>
|
||||
<button class="action primary" id="updateBtn" type="button">Обновить сейчас</button>
|
||||
</div>
|
||||
|
||||
<div class="top">
|
||||
<div class="logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3l6.5 17H16l-2.2-5.8H10.2L8 20H5.5L12 3z" fill="#f4fff9"/>
|
||||
<circle cx="12" cy="14.2" r="2.2" fill="#0c7a55"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-wrap">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<p class="tagline">NaiveProxy · несколько серверов</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span class="dot" id="dot"></span>
|
||||
<span id="statusText">Отключено</span>
|
||||
</div>
|
||||
|
||||
<label class="field" for="profile">Профиль</label>
|
||||
<div class="profile-bar">
|
||||
<select id="profile"></select>
|
||||
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль">+</button>
|
||||
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить профиль">×</button>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div>
|
||||
<label class="field" for="name">Название</label>
|
||||
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="proxy">Ссылка сервера</label>
|
||||
<input id="proxy" type="text" placeholder="naive+https://user:pass@host:443#name" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span>Системный прокси Windows</span>
|
||||
<label class="switch">
|
||||
<input id="sysproxy" type="checkbox" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action primary" id="toggleBtn" type="button">Подключить</button>
|
||||
<button class="action ghost" id="saveBtn" type="button">Сохранить профиль</button>
|
||||
<div class="tools">
|
||||
<button class="action ghost" id="pingBtn" type="button">Пинг серверов</button>
|
||||
<button class="action ghost" id="updCheckBtn" type="button">Проверить обновление</button>
|
||||
</div>
|
||||
<button class="action ghost" id="coreBtn" type="button">Установить / обновить naive core</button>
|
||||
</div>
|
||||
<div class="ping-list" id="pingList"></div>
|
||||
<p class="meta" id="meta">Загрузка…</p>
|
||||
|
||||
<section class="shop" aria-label="Купить подключение">
|
||||
<h3>Безопасное подключение</h3>
|
||||
<p>На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.</p>
|
||||
<button class="action primary" id="shopBtn" type="button">Купить на evilfox.win</button>
|
||||
<a class="shop-link" id="shopLink" href="https://evilfox.win/" rel="noopener">https://evilfox.win/</a>
|
||||
</section>
|
||||
<p class="ver" id="verLabel">Navis</p>
|
||||
</main>
|
||||
|
||||
<div class="modal-backdrop" id="modal">
|
||||
<div class="modal">
|
||||
<h2>Новый профиль</h2>
|
||||
<div class="stack">
|
||||
<div>
|
||||
<label class="field" for="newName">Название</label>
|
||||
<input id="newName" type="text" placeholder="Server EU" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="newProxy">Ссылка</label>
|
||||
<input id="newProxy" type="text" placeholder="naive+https://user:pass@host:443#name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="action ghost" id="cancelNew" type="button">Отмена</button>
|
||||
<button class="action primary" id="createNew" type="button">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const meta = $("meta");
|
||||
const btn = $("toggleBtn");
|
||||
const coreBtn = $("coreBtn");
|
||||
const saveBtn = $("saveBtn");
|
||||
const addBtn = $("addBtn");
|
||||
const delBtn = $("delBtn");
|
||||
const proxy = $("proxy");
|
||||
const nameInput = $("name");
|
||||
const sysproxy = $("sysproxy");
|
||||
const profile = $("profile");
|
||||
const dot = $("dot");
|
||||
const statusText = $("statusText");
|
||||
const modal = $("modal");
|
||||
const shopBtn = $("shopBtn");
|
||||
const shopLink = $("shopLink");
|
||||
const pingBtn = $("pingBtn");
|
||||
const updCheckBtn = $("updCheckBtn");
|
||||
const updateBanner = $("updateBanner");
|
||||
const updateBtn = $("updateBtn");
|
||||
const pingList = $("pingList");
|
||||
const verLabel = $("verLabel");
|
||||
const SHOP_URL = "https://evilfox.win/";
|
||||
|
||||
let busy = false;
|
||||
let connected = false;
|
||||
let formHydrated = false;
|
||||
let dirty = false;
|
||||
let profiles = [];
|
||||
|
||||
function markDirty() { dirty = true; }
|
||||
[proxy, nameInput, sysproxy].forEach((el) => {
|
||||
el.addEventListener("input", markDirty);
|
||||
el.addEventListener("change", markDirty);
|
||||
el.addEventListener("focus", markDirty);
|
||||
});
|
||||
|
||||
function setMeta(text, kind) {
|
||||
meta.textContent = text || "";
|
||||
meta.style.color = kind === "err" ? "var(--danger)" : kind === "ok" ? "var(--accent)" : "var(--muted)";
|
||||
}
|
||||
|
||||
function fillProfiles(list, active) {
|
||||
profiles = list || [];
|
||||
const cur = profile.value;
|
||||
profile.innerHTML = "";
|
||||
profiles.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = p.host ? (p.name + " · " + p.host) : p.name;
|
||||
profile.appendChild(opt);
|
||||
});
|
||||
const want = active || cur || (profiles[0] && profiles[0].name);
|
||||
if (want) profile.value = want;
|
||||
}
|
||||
|
||||
function renderPings(pings) {
|
||||
pingList.innerHTML = "";
|
||||
(pings || []).forEach((p) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ping-item";
|
||||
const left = document.createElement("span");
|
||||
left.textContent = p.host ? (p.name + " · " + p.host) : p.name;
|
||||
const right = document.createElement("span");
|
||||
right.className = "ms " + (p.ok ? "ok" : "bad");
|
||||
right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка");
|
||||
row.appendChild(left);
|
||||
row.appendChild(right);
|
||||
pingList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderUpdate(u, version) {
|
||||
verLabel.textContent = "Navis v" + (version || "?");
|
||||
if (!u) {
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
if (u.available) {
|
||||
updateBanner.classList.add("show");
|
||||
$("updateTitle").textContent = "Обновление " + u.latest;
|
||||
$("updateNotes").textContent = u.notes || ("Текущая версия " + u.current);
|
||||
} else {
|
||||
updateBanner.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
function paint(state, opts) {
|
||||
const syncForm = opts && opts.syncForm;
|
||||
connected = !!state.connected;
|
||||
dot.classList.toggle("on", connected);
|
||||
statusText.textContent = connected
|
||||
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
|
||||
: "Отключено";
|
||||
btn.textContent = connected ? "Отключить" : "Подключить";
|
||||
btn.classList.toggle("danger", connected);
|
||||
|
||||
const lock = connected || busy;
|
||||
proxy.disabled = lock;
|
||||
nameInput.disabled = lock;
|
||||
sysproxy.disabled = lock;
|
||||
profile.disabled = lock;
|
||||
addBtn.disabled = lock;
|
||||
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
|
||||
saveBtn.disabled = lock;
|
||||
coreBtn.disabled = busy;
|
||||
pingBtn.disabled = busy;
|
||||
updCheckBtn.disabled = busy;
|
||||
updateBtn.disabled = busy;
|
||||
btn.disabled = busy;
|
||||
|
||||
fillProfiles(state.profiles || [], state.active_profile || state.profile);
|
||||
renderPings(state.pings || []);
|
||||
renderUpdate(state.update, state.version);
|
||||
|
||||
if (syncForm || (!formHydrated && !dirty)) {
|
||||
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
|
||||
nameInput.value = active.name || state.active_profile || "";
|
||||
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
|
||||
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
|
||||
formHydrated = true;
|
||||
if (syncForm) dirty = false;
|
||||
}
|
||||
|
||||
let detail = "";
|
||||
if (connected) {
|
||||
const parts = [];
|
||||
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
|
||||
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
|
||||
detail = parts.join(" · ");
|
||||
} else if (state.core_ready === false) {
|
||||
detail = "Сначала установите official naive.exe";
|
||||
} else {
|
||||
detail = "Выберите профиль или создайте новый";
|
||||
}
|
||||
if (!busy) setMeta(detail, state.core_ready === false ? "err" : "");
|
||||
}
|
||||
|
||||
async function refresh(opts) {
|
||||
const state = await getState();
|
||||
paint(state, opts);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function withBusy(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = true; });
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
busy = false;
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
} catch (e) {
|
||||
setMeta(String(e), "err");
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profile.addEventListener("change", () => withBusy(async () => {
|
||||
await selectProfile(profile.value);
|
||||
dirty = false;
|
||||
formHydrated = false;
|
||||
}));
|
||||
|
||||
btn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
if (connected) {
|
||||
setMeta("Отключение…");
|
||||
await disconnect();
|
||||
setMeta("Отключено", "ok");
|
||||
} else {
|
||||
setMeta("Подключение…");
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
await connect();
|
||||
setMeta("Туннель активен", "ok");
|
||||
}
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
saveBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
|
||||
setMeta("Профиль сохранён", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
coreBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Скачивание official naiveproxy…");
|
||||
const path = await installCore();
|
||||
setMeta("Core установлен: " + path, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
delBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
|
||||
await deleteProfile(profile.value);
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Профиль удалён", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
$("newName").value = "";
|
||||
$("newProxy").value = "";
|
||||
modal.classList.add("open");
|
||||
$("newName").focus();
|
||||
});
|
||||
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
|
||||
$("createNew").addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
const n = $("newName").value.trim();
|
||||
const p = $("newProxy").value.trim();
|
||||
if (!n) throw "Укажите название профиля";
|
||||
await createProfile(n, p, !!sysproxy.checked);
|
||||
modal.classList.remove("open");
|
||||
formHydrated = false;
|
||||
dirty = false;
|
||||
setMeta("Профиль создан", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
async function openShop(e) {
|
||||
if (e) e.preventDefault();
|
||||
try {
|
||||
await openURL(SHOP_URL);
|
||||
} catch (err) {
|
||||
setMeta(String(err), "err");
|
||||
}
|
||||
}
|
||||
shopBtn.addEventListener("click", openShop);
|
||||
shopLink.addEventListener("click", openShop);
|
||||
|
||||
pingBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Проверка серверов…");
|
||||
const rows = await pingServers();
|
||||
renderPings(rows);
|
||||
const ok = (rows || []).filter((r) => r.ok).length;
|
||||
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
updCheckBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Проверка обновлений…");
|
||||
const st = await checkUpdate();
|
||||
renderUpdate(st, st.current);
|
||||
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
|
||||
else if (st.error) setMeta("Обновление: " + st.error, "err");
|
||||
else setMeta("У вас актуальная версия " + st.current, "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
updateBtn.addEventListener("click", () => withBusy(async () => {
|
||||
try {
|
||||
setMeta("Скачивание обновления…");
|
||||
await applyUpdate();
|
||||
setMeta("Обновление установлено, перезапуск…", "ok");
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
refresh({ syncForm: true }).catch((e) => setMeta(String(e), "err"));
|
||||
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,283 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Protocol identifies a VPN/proxy backend.
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
ProtocolNaive Protocol = "naive"
|
||||
)
|
||||
|
||||
// Config is the top-level client configuration.
|
||||
type Config struct {
|
||||
// Active is the name of the profile to use by default.
|
||||
Active string `json:"active"`
|
||||
|
||||
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
|
||||
// BinDir is where protocol binaries (naive.exe) are stored.
|
||||
BinDir string `json:"bin_dir,omitempty"`
|
||||
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
// Profile describes one connection endpoint.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Protocol Protocol `json:"protocol"`
|
||||
|
||||
// Naive: full proxy URI, e.g. https://user:pass@example.com or quic://...
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
|
||||
// Local listen URIs passed to naive. Default: socks + http for system proxy.
|
||||
Listen []string `json:"listen,omitempty"`
|
||||
|
||||
// Extra Naive options
|
||||
InsecureConcurrency int `json:"insecure_concurrency,omitempty"`
|
||||
ExtraHeaders string `json:"extra_headers,omitempty"`
|
||||
HostResolverRules string `json:"host_resolver_rules,omitempty"`
|
||||
NoPostQuantum bool `json:"no_post_quantum,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultListen returns listen addresses suitable for Windows system proxy.
|
||||
func (p Profile) DefaultListen() []string {
|
||||
if len(p.Listen) > 0 {
|
||||
return p.Listen
|
||||
}
|
||||
return []string{
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081",
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPListenHostPort returns host:port of the first http:// listen URI, if any.
|
||||
func (p Profile) HTTPListenHostPort() (string, bool) {
|
||||
for _, u := range p.DefaultListen() {
|
||||
if hostPort, ok := parseListenHostPort(u, "http"); ok {
|
||||
return hostPort, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SOCKSListenHostPort returns host:port of the first socks:// listen URI, if any.
|
||||
func (p Profile) SOCKSListenHostPort() (string, bool) {
|
||||
for _, u := range p.DefaultListen() {
|
||||
if hostPort, ok := parseListenHostPort(u, "socks"); ok {
|
||||
return hostPort, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseListenHostPort(uri, wantScheme string) (string, bool) {
|
||||
// Minimal parse: scheme://[user:pass@]host:port
|
||||
schemeSep := "://"
|
||||
i := index(uri, schemeSep)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
scheme := uri[:i]
|
||||
if scheme != wantScheme {
|
||||
return "", false
|
||||
}
|
||||
rest := uri[i+len(schemeSep):]
|
||||
if at := lastIndex(rest, "@"); at >= 0 {
|
||||
rest = rest[at+1:]
|
||||
}
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func index(s, substr string) int {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastIndex(s, substr string) int {
|
||||
for i := len(s) - len(substr); i >= 0; i-- {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Load reads and validates a config file.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
data = stripUTF8BOM(data)
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.BinDir == "" {
|
||||
cfg.BinDir = "bin"
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func stripUTF8BOM(data []byte) []byte {
|
||||
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
|
||||
return data[3:]
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Validate checks required fields.
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Profiles) == 0 {
|
||||
return fmt.Errorf("config: no profiles defined")
|
||||
}
|
||||
if c.Active == "" {
|
||||
c.Active = c.Profiles[0].Name
|
||||
}
|
||||
if _, err := c.ActiveProfile(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, p := range c.Profiles {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("config: profile[%d] missing name", i)
|
||||
}
|
||||
switch p.Protocol {
|
||||
case ProtocolNaive:
|
||||
// Proxy may be empty until the user pastes a share link in the UI.
|
||||
default:
|
||||
return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveProfile returns the selected profile.
|
||||
func (c *Config) ActiveProfile() (*Profile, error) {
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == c.Active {
|
||||
return &c.Profiles[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("config: active profile %q not found", c.Active)
|
||||
}
|
||||
|
||||
// ResolveBinDir returns an absolute bin directory.
|
||||
// Relative paths are resolved next to the executable (portable install layout).
|
||||
func ResolveBinDir(cfgPath, binDir string) (string, error) {
|
||||
if filepath.IsAbs(binDir) {
|
||||
return binDir, nil
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
return filepath.Abs(filepath.Join(filepath.Dir(exe), binDir))
|
||||
}
|
||||
base := filepath.Dir(cfgPath)
|
||||
if base == "." || base == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = cwd
|
||||
} else if filepath.Base(base) == "configs" {
|
||||
base = filepath.Dir(base)
|
||||
}
|
||||
return filepath.Abs(filepath.Join(base, binDir))
|
||||
}
|
||||
|
||||
// Example returns a starter configuration.
|
||||
func Example() Config {
|
||||
return Config{
|
||||
Active: "naive-main",
|
||||
SystemProxy: true,
|
||||
BinDir: "bin",
|
||||
Profiles: []Profile{
|
||||
{
|
||||
Name: "naive-main",
|
||||
Protocol: ProtocolNaive,
|
||||
Proxy: "",
|
||||
Listen: []string{
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WriteExample writes example config to path.
|
||||
func WriteExample(path string) error {
|
||||
return Save(path, Example())
|
||||
}
|
||||
|
||||
// Save writes config as indented JSON.
|
||||
func Save(path string, cfg Config) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// SetActiveProxy updates the active profile proxy URI.
|
||||
func (c *Config) SetActiveProxy(proxy string) error {
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == c.Active {
|
||||
c.Profiles[i].Proxy = proxy
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("active profile %q not found", c.Active)
|
||||
}
|
||||
|
||||
// LocateConfig finds configs/config.json next to the executable or in cwd.
|
||||
func LocateConfig() (string, error) {
|
||||
candidates := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, "configs", "config.json"),
|
||||
filepath.Join(dir, "config.json"),
|
||||
)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "configs", "config.json"))
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// Default path next to executable for first-run create.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
return filepath.Join(filepath.Dir(exe), "configs", "config.json"), nil
|
||||
}
|
||||
return filepath.Join("configs", "config.json"), nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
|
||||
type ProfileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Proxy string `json:"proxy"`
|
||||
Host string `json:"host"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// ListProfiles returns UI-friendly profile summaries.
|
||||
func (c *Config) ListProfiles() []ProfileInfo {
|
||||
out := make([]ProfileInfo, 0, len(c.Profiles))
|
||||
for _, p := range c.Profiles {
|
||||
out = append(out, ProfileInfo{
|
||||
Name: p.Name,
|
||||
Protocol: string(p.Protocol),
|
||||
Proxy: p.Proxy,
|
||||
Host: proxyHost(p.Proxy),
|
||||
Active: p.Name == c.Active,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func proxyHost(proxy string) string {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.Index(proxy, "://"); i >= 0 {
|
||||
proxy = proxy[i+3:]
|
||||
}
|
||||
if at := strings.LastIndex(proxy, "@"); at >= 0 {
|
||||
proxy = proxy[at+1:]
|
||||
}
|
||||
if i := strings.IndexAny(proxy, "/?#"); i >= 0 {
|
||||
proxy = proxy[:i]
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
// SetActive switches the active profile name.
|
||||
func (c *Config) SetActive(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, p := range c.Profiles {
|
||||
if p.Name == name {
|
||||
c.Active = name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("profile %q not found", name)
|
||||
}
|
||||
|
||||
// UpsertProfile creates or updates a profile by name.
|
||||
func (c *Config) UpsertProfile(name, proxy string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == name {
|
||||
c.Profiles[i].Proxy = proxy
|
||||
if c.Profiles[i].Protocol == "" {
|
||||
c.Profiles[i].Protocol = ProtocolNaive
|
||||
}
|
||||
if len(c.Profiles[i].Listen) == 0 {
|
||||
c.Profiles[i].Listen = defaultListen()
|
||||
}
|
||||
c.Active = name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
c.Profiles = append(c.Profiles, Profile{
|
||||
Name: name,
|
||||
Protocol: ProtocolNaive,
|
||||
Proxy: proxy,
|
||||
Listen: defaultListen(),
|
||||
})
|
||||
c.Active = name
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProfile renames a profile and keeps active pointer in sync.
|
||||
func (c *Config) RenameProfile(oldName, newName string) error {
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
for _, p := range c.Profiles {
|
||||
if p.Name == newName {
|
||||
return fmt.Errorf("профиль %q уже существует", newName)
|
||||
}
|
||||
}
|
||||
for i := range c.Profiles {
|
||||
if c.Profiles[i].Name == oldName {
|
||||
c.Profiles[i].Name = newName
|
||||
if c.Active == oldName {
|
||||
c.Active = newName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("profile %q not found", oldName)
|
||||
}
|
||||
|
||||
// DeleteProfile removes a profile. Keeps at least one profile.
|
||||
func (c *Config) DeleteProfile(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if len(c.Profiles) <= 1 {
|
||||
return fmt.Errorf("нельзя удалить последний профиль")
|
||||
}
|
||||
idx := -1
|
||||
for i, p := range c.Profiles {
|
||||
if p.Name == name {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("profile %q not found", name)
|
||||
}
|
||||
c.Profiles = append(c.Profiles[:idx], c.Profiles[idx+1:]...)
|
||||
if c.Active == name {
|
||||
c.Active = c.Profiles[0].Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultListen() []string {
|
||||
return []string{
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Engine runs one protocol backend (e.g. official naive.exe).
|
||||
type Engine interface {
|
||||
Protocol() config.Protocol
|
||||
Start(ctx context.Context, profile config.Profile, binDir string) error
|
||||
Stop() error
|
||||
Running() bool
|
||||
LocalHTTPProxy() (hostPort string, ok bool)
|
||||
LocalSOCKSProxy() (hostPort string, ok bool)
|
||||
}
|
||||
|
||||
// Status is a snapshot of the connection manager.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Protocol config.Protocol `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/sysproxy"
|
||||
)
|
||||
|
||||
// Manager orchestrates protocol engines and Windows system proxy.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
engine Engine
|
||||
sys sysproxy.Controller
|
||||
profile *config.Profile
|
||||
stderr io.Writer
|
||||
binDir string
|
||||
}
|
||||
|
||||
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
binDir, err := config.ResolveBinDir(cfgPath, cfg.BinDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
cfgPath: cfgPath,
|
||||
cfg: cfg,
|
||||
sys: sysproxy.New(),
|
||||
stderr: stderr,
|
||||
binDir: binDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Connect(ctx context.Context, profileName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("already connected; disconnect first")
|
||||
}
|
||||
|
||||
if profileName != "" {
|
||||
m.cfg.Active = profileName
|
||||
}
|
||||
profile, err := m.cfg.ActiveProfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
engine, err := m.newEngine(profile.Protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := engine.Start(ctx, *profile, m.binDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.cfg.SystemProxy {
|
||||
httpProxy, ok := engine.LocalHTTPProxy()
|
||||
if !ok {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
|
||||
}
|
||||
if err := m.sys.Enable(httpProxy); err != nil {
|
||||
_ = engine.Stop()
|
||||
return fmt.Errorf("enable system proxy: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.engine = engine
|
||||
m.profile = profile
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Disconnect() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var first error
|
||||
if m.sys.Enabled() {
|
||||
if err := m.sys.Disable(); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
if m.engine != nil {
|
||||
if err := m.engine.Stop(); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
m.engine = nil
|
||||
}
|
||||
m.profile = nil
|
||||
return first
|
||||
}
|
||||
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
st := Status{SystemProxy: m.sys.Enabled()}
|
||||
if m.engine == nil || !m.engine.Running() {
|
||||
return st
|
||||
}
|
||||
st.Connected = true
|
||||
if m.profile != nil {
|
||||
st.Profile = m.profile.Name
|
||||
st.Protocol = m.profile.Protocol
|
||||
}
|
||||
if hp, ok := m.engine.LocalHTTPProxy(); ok {
|
||||
st.HTTPProxy = hp
|
||||
}
|
||||
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
|
||||
st.SOCKSProxy = sp
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Probe fetches url via the local HTTP or SOCKS proxy to verify the tunnel.
|
||||
func (m *Manager) Probe(ctx context.Context, testURL string) error {
|
||||
m.mu.Lock()
|
||||
engine := m.engine
|
||||
m.mu.Unlock()
|
||||
if engine == nil || !engine.Running() {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if testURL == "" {
|
||||
testURL = "https://www.google.com/generate_204"
|
||||
}
|
||||
|
||||
var proxyURL *url.URL
|
||||
if hp, ok := engine.LocalHTTPProxy(); ok {
|
||||
proxyURL, _ = url.Parse("http://" + hp)
|
||||
} else if sp, ok := engine.LocalSOCKSProxy(); ok {
|
||||
proxyURL, _ = url.Parse("socks5://" + sp)
|
||||
} else {
|
||||
return fmt.Errorf("no local proxy listen address")
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("probe via %s: %w", proxyURL.String(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("probe unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) BinDir() string { return m.binDir }
|
||||
|
||||
func (m *Manager) ConfigPath() string { return m.cfgPath }
|
||||
|
||||
func (m *Manager) Config() *config.Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
}
|
||||
|
||||
func (m *Manager) SetSystemProxy(enabled bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg.SystemProxy = enabled
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateProxyURI(proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.cfg.SetActiveProxy(normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) SetActiveProfile(name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
if err := m.cfg.SetActive(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) SaveProfile(name, proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy != "" {
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxy = normalized
|
||||
}
|
||||
if err := m.cfg.UpsertProfile(name, proxy); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
// SaveActiveProfile updates the current profile (rename if needed) and proxy URI.
|
||||
func (m *Manager) SaveActiveProfile(name, proxy string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("имя профиля пустое")
|
||||
}
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy != "" {
|
||||
normalized, err := naive.NormalizeProxyURI(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxy = normalized
|
||||
}
|
||||
|
||||
active := m.cfg.Active
|
||||
if name != active {
|
||||
exists := false
|
||||
for _, p := range m.cfg.Profiles {
|
||||
if p.Name == name {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
if err := m.cfg.SetActive(name); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := m.cfg.RenameProfile(active, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := m.cfg.SetActiveProxy(proxy); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) DeleteProfile(name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("сначала отключитесь")
|
||||
}
|
||||
if err := m.cfg.DeleteProfile(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) Profiles() []config.ProfileInfo {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg.ListProfiles()
|
||||
}
|
||||
|
||||
func (m *Manager) SaveConfig() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return config.Save(m.cfgPath, *m.cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.engine != nil && m.engine.Running() {
|
||||
return fmt.Errorf("disconnect before reloading config")
|
||||
}
|
||||
cfg, err := config.Load(m.cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binDir, err := config.ResolveBinDir(m.cfgPath, cfg.BinDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.cfg = cfg
|
||||
m.binDir = binDir
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
|
||||
switch p {
|
||||
case config.ProtocolNaive:
|
||||
return naive.New(m.stderr), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol %q", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package netcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
|
||||
// Result is a TCP reachability measurement to a proxy host.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Ms int64 `json:"ms"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PingProxyURI measures TCP connect time to the host in a naive share/proxy URI.
|
||||
func PingProxyURI(ctx context.Context, name, proxyURI string) Result {
|
||||
res := Result{Name: name}
|
||||
if strings.TrimSpace(proxyURI) == "" {
|
||||
res.Error = "нет ссылки сервера"
|
||||
return res
|
||||
}
|
||||
normalized, err := naive.NormalizeProxyURI(proxyURI)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https", "quic":
|
||||
port = "443"
|
||||
default:
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
res.Host = host
|
||||
res.Port = port
|
||||
if host == "" {
|
||||
res.Error = "нет хоста"
|
||||
return res
|
||||
}
|
||||
|
||||
d := net.Dialer{Timeout: 4 * time.Second}
|
||||
start := time.Now()
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
res.Error = fmt.Sprintf("недоступен: %v", err)
|
||||
return res
|
||||
}
|
||||
_ = conn.Close()
|
||||
res.Ms = time.Since(start).Milliseconds()
|
||||
res.OK = true
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package naive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const githubAPILatest = "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest"
|
||||
|
||||
// ResolveBinary finds naive.exe/naive in binDir or PATH.
|
||||
func ResolveBinary(binDir string) (string, error) {
|
||||
name := "naive"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "naive.exe"
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(binDir, name),
|
||||
filepath.Join(binDir, "naiveproxy", name),
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), name))
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "bin", name))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("naive binary not found (looked in %s); run: vpnclient install-core", binDir)
|
||||
}
|
||||
|
||||
// EnsureBinary downloads the official Windows/Linux/macOS release if missing.
|
||||
func EnsureBinary(binDir string) (string, error) {
|
||||
if path, err := ResolveBinary(binDir); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pattern, err := assetNameForPlatform()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "downloading official naiveproxy release (%s)...\n", pattern)
|
||||
assetName, url, err := findReleaseAsset(pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zipPath := filepath.Join(binDir, assetName)
|
||||
if err := downloadFile(zipPath, url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
|
||||
if err := unzipNaive(zipPath, binDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ResolveBinary(binDir)
|
||||
}
|
||||
|
||||
func assetNameForPlatform() (string, error) {
|
||||
switch {
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
|
||||
return "naiveproxy-*-win-x64.zip", nil
|
||||
case runtime.GOOS == "windows" && runtime.GOARCH == "arm64":
|
||||
return "naiveproxy-*-win-arm64.zip", nil
|
||||
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
|
||||
return "naiveproxy-*-linux-x64.zip", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
|
||||
return "naiveproxy-*-mac-x64.tar.xz", nil
|
||||
case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
|
||||
return "naiveproxy-*-mac-arm64.tar.xz", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform %s/%s for auto-download; place naive binary in bin/", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func findReleaseAsset(pattern string) (name, downloadURL string, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "vpnclient-go")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
prefix := strings.Split(pattern, "*")[0]
|
||||
suffix := ""
|
||||
if parts := strings.Split(pattern, "*"); len(parts) == 2 {
|
||||
suffix = parts[1]
|
||||
}
|
||||
for _, a := range rel.Assets {
|
||||
if strings.HasPrefix(a.Name, prefix) && strings.HasSuffix(a.Name, suffix) {
|
||||
if strings.Contains(a.Name, ".tar.") {
|
||||
return "", "", fmt.Errorf("auto-extract of %s not implemented; download manually from %s", a.Name, a.BrowserDownloadURL)
|
||||
}
|
||||
return a.Name, a.BrowserDownloadURL, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no asset matching %s in release %s", pattern, rel.TagName)
|
||||
}
|
||||
|
||||
func downloadFile(path, url string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func unzipNaive(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
want := "naive"
|
||||
if runtime.GOOS == "windows" {
|
||||
want = "naive.exe"
|
||||
}
|
||||
var found bool
|
||||
for _, f := range r.File {
|
||||
base := filepath.Base(f.Name)
|
||||
if base != want {
|
||||
continue
|
||||
}
|
||||
if err := extractFile(f, filepath.Join(destDir, want)); err != nil {
|
||||
return err
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("zip does not contain %s", want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFile(f *zip.File, dest string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package naive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
// Engine runs the official Chromium-based naive binary.
|
||||
// NaiveProxy cannot be reimplemented in pure Go: TLS/H2 fingerprints
|
||||
// must match Chrome, which requires the upstream naive.exe.
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
workdir string
|
||||
profile config.Profile
|
||||
stderr io.Writer
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(stderr io.Writer) *Engine {
|
||||
if stderr == nil {
|
||||
stderr = os.Stderr
|
||||
}
|
||||
return &Engine{stderr: stderr}
|
||||
}
|
||||
|
||||
func (e *Engine) Protocol() config.Protocol { return config.ProtocolNaive }
|
||||
|
||||
func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.running {
|
||||
return fmt.Errorf("naive: already running")
|
||||
}
|
||||
|
||||
bin, err := ResolveBinary(binDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workdir, err := os.MkdirTemp("", "vpnclient-naive-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("naive: temp dir: %w", err)
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(workdir, "config.json")
|
||||
if err := writeRuntimeConfig(cfgPath, profile); err != nil {
|
||||
os.RemoveAll(workdir)
|
||||
return err
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(runCtx, bin, cfgPath)
|
||||
cmd.Dir = workdir
|
||||
cmd.Stdout = e.stderr
|
||||
cmd.Stderr = e.stderr
|
||||
applySysProcAttr(cmd)
|
||||
env := os.Environ()
|
||||
for k, v := range profile.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
os.RemoveAll(workdir)
|
||||
return fmt.Errorf("naive: start %s: %w", bin, err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
e.cmd = cmd
|
||||
e.cancel = cancel
|
||||
e.done = done
|
||||
e.workdir = workdir
|
||||
e.profile = profile
|
||||
e.running = true
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
fmt.Fprintf(e.stderr, "naive: process ended: %v\n", err)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.cleanupLocked()
|
||||
e.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Release lock while waiting so the Wait goroutine can clean up.
|
||||
e.mu.Unlock()
|
||||
timer := time.NewTimer(800 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
var startErr error
|
||||
select {
|
||||
case <-done:
|
||||
startErr = fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary")
|
||||
case <-ctx.Done():
|
||||
_ = e.Stop()
|
||||
startErr = ctx.Err()
|
||||
case <-timer.C:
|
||||
e.mu.Lock()
|
||||
alive := e.running
|
||||
e.mu.Unlock()
|
||||
if !alive {
|
||||
startErr = fmt.Errorf("naive: process exited during startup")
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
return startErr
|
||||
}
|
||||
|
||||
func (e *Engine) Stop() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.stopLocked()
|
||||
}
|
||||
|
||||
func (e *Engine) stopLocked() error {
|
||||
if !e.running || e.cmd == nil {
|
||||
return nil
|
||||
}
|
||||
done := e.done
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
proc := e.cmd.Process
|
||||
e.mu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if proc != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
e.mu.Lock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupLocked() {
|
||||
if e.workdir != "" {
|
||||
_ = os.RemoveAll(e.workdir)
|
||||
e.workdir = ""
|
||||
}
|
||||
e.cmd = nil
|
||||
e.cancel = nil
|
||||
e.running = false
|
||||
}
|
||||
|
||||
func (e *Engine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
|
||||
func (e *Engine) LocalHTTPProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.HTTPListenHostPort()
|
||||
}
|
||||
|
||||
func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.running {
|
||||
return "", false
|
||||
}
|
||||
return e.profile.SOCKSListenHostPort()
|
||||
}
|
||||
|
||||
type runtimeConfig struct {
|
||||
Listen any `json:"listen"`
|
||||
Proxy string `json:"proxy"`
|
||||
InsecureConcurrency int `json:"insecure-concurrency,omitempty"`
|
||||
ExtraHeaders string `json:"extra-headers,omitempty"`
|
||||
HostResolverRules string `json:"host-resolver-rules,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
NoPostQuantum bool `json:"no-post-quantum,omitempty"`
|
||||
}
|
||||
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
listen := profile.DefaultListen()
|
||||
var listenField any = listen
|
||||
if len(listen) == 1 {
|
||||
listenField = listen[0]
|
||||
}
|
||||
proxy, err := NormalizeProxyURI(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc := runtimeConfig{
|
||||
Listen: listenField,
|
||||
Proxy: proxy,
|
||||
InsecureConcurrency: profile.InsecureConcurrency,
|
||||
ExtraHeaders: profile.ExtraHeaders,
|
||||
HostResolverRules: profile.HostResolverRules,
|
||||
Log: profile.Log,
|
||||
NoPostQuantum: profile.NoPostQuantum,
|
||||
}
|
||||
data, err := json.MarshalIndent(rc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package naive
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package naive
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func applySysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package naive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeProxyURI converts share links and aliases into a naive --proxy URI.
|
||||
//
|
||||
// Supported inputs:
|
||||
// - https://user:pass@host:443
|
||||
// - quic://user:pass@host
|
||||
// - naive+https://user:pass@host:443#remark
|
||||
// - naive+quic://user:pass@host#remark
|
||||
func NormalizeProxyURI(raw string) (string, error) {
|
||||
uri, _, err := ParseShareLink(raw)
|
||||
return uri, err
|
||||
}
|
||||
|
||||
// ParseShareLink returns a normalized proxy URI and optional remark from #fragment.
|
||||
func ParseShareLink(raw string) (proxyURI, remark string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("empty proxy URI")
|
||||
}
|
||||
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
remark = strings.TrimSpace(raw[i+1:])
|
||||
raw = raw[:i]
|
||||
}
|
||||
|
||||
lower := strings.ToLower(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "naive+https://"):
|
||||
raw = "https://" + raw[len("naive+https://"):]
|
||||
case strings.HasPrefix(lower, "naive+quic://"):
|
||||
raw = "quic://" + raw[len("naive+quic://"):]
|
||||
case strings.HasPrefix(lower, "naive://"):
|
||||
raw = "https://" + raw[len("naive://"):]
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse proxy URI: %w", err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https", "quic", "http":
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported proxy scheme %q (use https://, quic:// or naive+https://)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", "", fmt.Errorf("proxy URI missing host")
|
||||
}
|
||||
if u.User == nil || u.User.Username() == "" {
|
||||
return "", "", fmt.Errorf("proxy URI missing username")
|
||||
}
|
||||
if _, ok := u.User.Password(); !ok {
|
||||
return "", "", fmt.Errorf("proxy URI missing password")
|
||||
}
|
||||
|
||||
out := &url.URL{
|
||||
Scheme: strings.ToLower(u.Scheme),
|
||||
User: u.User,
|
||||
Host: u.Host,
|
||||
}
|
||||
return out.String(), remark, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package naive
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeProxyURI(t *testing.T) {
|
||||
in := "naive+https://user:pass@de50.example.com:443#site_u1_s454"
|
||||
got, err := NormalizeProxyURI(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://user:pass@de50.example.com:443"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
|
||||
got, err = NormalizeProxyURI("quic://user:pass@host")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "quic://user:pass@host" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package sysproxy
|
||||
|
||||
type stubController struct{}
|
||||
|
||||
func newPlatform() Controller { return stubController{} }
|
||||
|
||||
func (stubController) Enable(string) error { return ErrUnsupported }
|
||||
func (stubController) Disable() error { return nil }
|
||||
func (stubController) Enabled() bool { return false }
|
||||
@@ -0,0 +1,18 @@
|
||||
package sysproxy
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Controller manages OS-level proxy settings.
|
||||
type Controller interface {
|
||||
Enable(httpHostPort string) error
|
||||
Disable() error
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// New returns a platform-specific controller.
|
||||
func New() Controller {
|
||||
return newPlatform()
|
||||
}
|
||||
|
||||
// ErrUnsupported is returned on platforms without system proxy support.
|
||||
var ErrUnsupported = fmt.Errorf("system proxy is not supported on this platform")
|
||||
@@ -0,0 +1,154 @@
|
||||
//go:build windows
|
||||
|
||||
package sysproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
internetOptionSettingsChanged = 39
|
||||
internetOptionRefresh = 37
|
||||
)
|
||||
|
||||
type windowsController struct {
|
||||
enabled bool
|
||||
hadProxy bool
|
||||
oldEnable uint64
|
||||
oldServer string
|
||||
oldOverride string
|
||||
}
|
||||
|
||||
func newPlatform() Controller {
|
||||
return &windowsController{}
|
||||
}
|
||||
|
||||
func (c *windowsController) Enabled() bool { return c.enabled }
|
||||
|
||||
func (c *windowsController) Enable(httpHostPort string) error {
|
||||
if httpHostPort == "" {
|
||||
return fmt.Errorf("sysproxy: empty http proxy address")
|
||||
}
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
registry.QUERY_VALUE|registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sysproxy: open registry: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if !c.enabled {
|
||||
c.oldEnable, _, _ = key.GetIntegerValue("ProxyEnable")
|
||||
c.oldServer, _, _ = key.GetStringValue("ProxyServer")
|
||||
c.oldOverride, _, _ = key.GetStringValue("ProxyOverride")
|
||||
c.hadProxy = true
|
||||
}
|
||||
|
||||
override := strings.Join([]string{
|
||||
"localhost",
|
||||
"127.*",
|
||||
"10.*",
|
||||
"172.16.*",
|
||||
"172.17.*",
|
||||
"172.18.*",
|
||||
"172.19.*",
|
||||
"172.20.*",
|
||||
"172.21.*",
|
||||
"172.22.*",
|
||||
"172.23.*",
|
||||
"172.24.*",
|
||||
"172.25.*",
|
||||
"172.26.*",
|
||||
"172.27.*",
|
||||
"172.28.*",
|
||||
"172.29.*",
|
||||
"172.30.*",
|
||||
"172.31.*",
|
||||
"192.168.*",
|
||||
"<local>",
|
||||
}, ";")
|
||||
|
||||
if err := key.SetDWordValue("ProxyEnable", 1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := key.SetStringValue("ProxyServer", httpHostPort); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := key.SetStringValue("ProxyOverride", override); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := notifyInternetSettingsChanged(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = setWinHTTPProxy(httpHostPort)
|
||||
|
||||
c.enabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *windowsController) Disable() error {
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
registry.QUERY_VALUE|registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if c.hadProxy {
|
||||
_ = key.SetDWordValue("ProxyEnable", uint32(c.oldEnable))
|
||||
if c.oldServer != "" {
|
||||
_ = key.SetStringValue("ProxyServer", c.oldServer)
|
||||
} else {
|
||||
_ = key.DeleteValue("ProxyServer")
|
||||
}
|
||||
if c.oldOverride != "" {
|
||||
_ = key.SetStringValue("ProxyOverride", c.oldOverride)
|
||||
}
|
||||
} else {
|
||||
_ = key.SetDWordValue("ProxyEnable", 0)
|
||||
}
|
||||
|
||||
_ = notifyInternetSettingsChanged()
|
||||
_ = resetWinHTTPProxy()
|
||||
|
||||
c.enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func notifyInternetSettingsChanged() error {
|
||||
wininet := windows.NewLazySystemDLL("wininet.dll")
|
||||
proc := wininet.NewProc("InternetSetOptionW")
|
||||
r1, _, err := proc.Call(0, uintptr(internetOptionSettingsChanged), 0, 0)
|
||||
if r1 == 0 {
|
||||
return fmt.Errorf("InternetSetOption(SETTINGS_CHANGED): %w", err)
|
||||
}
|
||||
r1, _, err = proc.Call(0, uintptr(internetOptionRefresh), 0, 0)
|
||||
if r1 == 0 {
|
||||
return fmt.Errorf("InternetSetOption(REFRESH): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setWinHTTPProxy(httpHostPort string) error {
|
||||
cmd := exec.Command("netsh", "winhttp", "set", "proxy", httpHostPort,
|
||||
"bypass-list=localhost;127.*;10.*;192.168.*;<local>")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func resetWinHTTPProxy() error {
|
||||
cmd := exec.Command("netsh", "winhttp", "reset", "proxy")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "1.1.2"
|
||||
|
||||
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
||||
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||
|
||||
// Manifest describes a remote release.
|
||||
type Manifest struct {
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Mandatory bool `json:"mandatory,omitempty"`
|
||||
}
|
||||
|
||||
// Status is returned to the UI.
|
||||
type Status struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
Checking bool `json:"checking,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ManifestURL string `json:"manifest_url"`
|
||||
}
|
||||
|
||||
// Check fetches the manifest and compares versions.
|
||||
func Check(ctx context.Context, manifestURL string) (Status, error) {
|
||||
if manifestURL == "" {
|
||||
manifestURL = DefaultManifestURL
|
||||
}
|
||||
st := Status{Current: CurrentVersion, ManifestURL: manifestURL}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+CurrentVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("update feed HTTP %d", resp.StatusCode)
|
||||
st.Error = err.Error()
|
||||
return st, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
|
||||
st.Error = err.Error()
|
||||
return st, err
|
||||
}
|
||||
st.Latest = strings.TrimSpace(m.Version)
|
||||
st.Notes = m.Notes
|
||||
st.URL = m.URL
|
||||
st.Mandatory = m.Mandatory
|
||||
st.Available = Compare(st.Latest, CurrentVersion) > 0 && m.URL != ""
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Compare returns 1 if a>b, -1 if a<b, 0 if equal (semver-ish dotted ints).
|
||||
func Compare(a, b string) int {
|
||||
as := splitVer(a)
|
||||
bs := splitVer(b)
|
||||
n := len(as)
|
||||
if len(bs) > n {
|
||||
n = len(bs)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
var ai, bi int
|
||||
if i < len(as) {
|
||||
ai = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
bi = bs[i]
|
||||
}
|
||||
if ai > bi {
|
||||
return 1
|
||||
}
|
||||
if ai < bi {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitVer(v string) []int {
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
parts := strings.Split(v, ".")
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
// strip pre-release suffix: 1.2.3-beta
|
||||
if i := strings.IndexAny(p, "-+"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Apply downloads the new exe and schedules replacement after exit.
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !st.Available {
|
||||
return "", fmt.Errorf("обновление не найдено")
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exe, err = filepath.Abs(exe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
tmp := filepath.Join(dir, "Navis.exe.new")
|
||||
bat := filepath.Join(dir, "navis-update.bat")
|
||||
|
||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
man, err := fetchManifest(ctx, manifestURL)
|
||||
if err == nil && man.SHA256 != "" {
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(sum, man.SHA256) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// Important: do NOT self-delete the .bat at the end — that causes
|
||||
// "Не удается найти пакетный файл" on paths with spaces (e.g. D:\vpn navi).
|
||||
script := "@echo off\r\n" +
|
||||
"setlocal EnableExtensions\r\n" +
|
||||
"cd /d \"" + dir + "\"\r\n" +
|
||||
"set \"EXE=" + exe + "\"\r\n" +
|
||||
"set \"NEW=" + tmp + "\"\r\n" +
|
||||
":wait\r\n" +
|
||||
"ping -n 2 127.0.0.1 >nul\r\n" +
|
||||
"del /f /q \"%EXE%\" >nul 2>&1\r\n" +
|
||||
"if exist \"%EXE%\" goto wait\r\n" +
|
||||
"move /y \"%NEW%\" \"%EXE%\" >nul\r\n" +
|
||||
"if not exist \"%EXE%\" exit /b 1\r\n" +
|
||||
"start \"\" \"%EXE%\"\r\n" +
|
||||
"exit /b 0\r\n"
|
||||
if err := os.WriteFile(bat, []byte(script), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Detached cmd so Apply returns while updater keeps running.
|
||||
cmd := exec.Command("cmd.exe", "/C", bat)
|
||||
cmd.Dir = dir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start updater: %w", err)
|
||||
}
|
||||
return st.Latest, nil
|
||||
}
|
||||
|
||||
func allowedDownloadURL(u string) bool {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
return strings.HasPrefix(u, "https://git.evilfox.cc/") ||
|
||||
strings.HasPrefix(u, "https://files.de4ima.uk/")
|
||||
}
|
||||
|
||||
func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) {
|
||||
if manifestURL == "" {
|
||||
manifestURL = DefaultManifestURL
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+CurrentVersion)
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m Manifest
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m)
|
||||
return m, err
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url, dest string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+CurrentVersion)
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 200<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package update
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
if Compare("1.1.0", "1.0.0") <= 0 {
|
||||
t.Fatal("expected newer")
|
||||
}
|
||||
if Compare("1.0.0", "1.1.0") >= 0 {
|
||||
t.Fatal("expected older")
|
||||
}
|
||||
if Compare("1.1.0", "1.1.0") != 0 {
|
||||
t.Fatal("expected equal")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user