Initial Navis client with NaiveProxy, profiles, ping and git updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-28 06:55:19 +03:00
co-authored by Cursor
commit 595bc3d70d
40 changed files with 3412 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
package appui
import _ "embed"
//go:embed index.html
var IndexHTML string
+625
View File
@@ -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>
+283
View File
@@ -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
}
+148
View File
@@ -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",
}
}
+28
View File
@@ -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"`
}
+338
View File
@@ -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)
}
}
+69
View File
@@ -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
}
+192
View File
@@ -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
}
+221
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package naive
import "os/exec"
func applySysProcAttr(cmd *exec.Cmd) {}
+12
View File
@@ -0,0 +1,12 @@
//go:build windows
package naive
import (
"os/exec"
"syscall"
)
func applySysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
+68
View File
@@ -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
}
+23
View File
@@ -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)
}
}
+11
View File
@@ -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 }
+18
View File
@@ -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")
+154
View File
@@ -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()
}
+266
View File
@@ -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
}
+15
View File
@@ -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")
}
}