Initial Navis client with NaiveProxy, profiles, ping and git updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
bin/
|
||||
*.exe
|
||||
!dist/
|
||||
!dist/**/*.exe
|
||||
!dist/**/
|
||||
configs/config.json
|
||||
configs/.navis-webview/
|
||||
cmd/vpnapp/resource.syso
|
||||
*.syso
|
||||
.navis-webview/
|
||||
navis-update.bat
|
||||
Navis.exe.new
|
||||
@@ -0,0 +1,28 @@
|
||||
# Navis
|
||||
|
||||
Windows-клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) с профилями, пингом серверов и автообновлением.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
```
|
||||
|
||||
Или:
|
||||
|
||||
```bat
|
||||
go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp
|
||||
```
|
||||
|
||||
## Обновления
|
||||
|
||||
Клиент читает манифест из этого репозитория:
|
||||
|
||||
- Manifest: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json`
|
||||
- Binary: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe`
|
||||
|
||||
Чтобы выкатить релиз: соберите `Navis.exe`, положите в `dist/navis-release/`, обновите `dist/update.json` (version + sha256), сделайте commit и push в `main`.
|
||||
|
||||
## Купить доступ
|
||||
|
||||
https://evilfox.win/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 890 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo Generating Windows icon resources...
|
||||
go run ./tools/mkico
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
where goversioninfo >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest
|
||||
)
|
||||
|
||||
goversioninfo -icon assets\navis.ico -o cmd\vpnapp\resource.syso versioninfo.json
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo Building Navis GUI and CLI...
|
||||
go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
go build -ldflags="-s -w" -o vpnclient.exe ./cmd/vpnclient
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo.
|
||||
echo Done:
|
||||
echo Navis.exe
|
||||
echo vpnclient.exe
|
||||
echo assets\navis.ico
|
||||
endlocal
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "Navis GUI is Windows-only (WebView2). Use vpnclient CLI on this platform.")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/jchv/go-webview2"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"vpnclient/internal/appui"
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/netcheck"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
"vpnclient/internal/update"
|
||||
)
|
||||
|
||||
type app struct {
|
||||
mu sync.Mutex
|
||||
mgr *core.Manager
|
||||
cfgPath string
|
||||
logBuf *bytes.Buffer
|
||||
updateStatus update.Status
|
||||
pings []netcheck.Result
|
||||
webview webview2.WebView
|
||||
}
|
||||
|
||||
type uiState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ActiveProfile string `json:"active_profile,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
HTTPProxy string `json:"http_proxy,omitempty"`
|
||||
SOCKSProxy string `json:"socks_proxy,omitempty"`
|
||||
SystemProxy bool `json:"system_proxy"`
|
||||
Proxy string `json:"proxy"`
|
||||
CoreReady bool `json:"core_ready"`
|
||||
CorePath string `json:"core_path,omitempty"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
Profiles []config.ProfileInfo `json:"profiles"`
|
||||
Version string `json:"version"`
|
||||
Update update.Status `json:"update"`
|
||||
Pings []netcheck.Result `json:"pings"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
cfgPath, err := config.LocateConfig()
|
||||
if err != nil {
|
||||
fatalDialog("Не удалось найти путь конфига: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
if err := config.WriteExample(cfgPath); err != nil {
|
||||
fatalDialog("Не удалось создать конфиг: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
|
||||
}
|
||||
|
||||
logBuf := &bytes.Buffer{}
|
||||
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
|
||||
if err != nil {
|
||||
fatalDialog("%v", err)
|
||||
}
|
||||
|
||||
a := &app{
|
||||
mgr: mgr,
|
||||
cfgPath: cfgPath,
|
||||
logBuf: logBuf,
|
||||
updateStatus: update.Status{
|
||||
Current: update.CurrentVersion,
|
||||
ManifestURL: update.DefaultManifestURL,
|
||||
},
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
|
||||
_ = os.MkdirAll(dataPath, 0o755)
|
||||
|
||||
go func() {
|
||||
if _, err := naive.ResolveBinary(mgr.BinDir()); err == nil {
|
||||
return
|
||||
}
|
||||
_, _ = naive.EnsureBinary(mgr.BinDir())
|
||||
}()
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
Debug: false,
|
||||
AutoFocus: true,
|
||||
DataPath: dataPath,
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "Navis",
|
||||
Width: 520,
|
||||
Height: 860,
|
||||
Center: true,
|
||||
IconId: 1,
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
|
||||
}
|
||||
a.webview = w
|
||||
defer func() {
|
||||
_ = a.mgr.Disconnect()
|
||||
w.Destroy()
|
||||
}()
|
||||
|
||||
applyWindowIcon(w.Window())
|
||||
w.SetSize(520, 860, webview2.HintNone)
|
||||
|
||||
mustBind(w, "getState", a.getState)
|
||||
mustBind(w, "connect", a.connect)
|
||||
mustBind(w, "disconnect", a.disconnect)
|
||||
mustBind(w, "saveProfile", a.saveProfile)
|
||||
mustBind(w, "createProfile", a.createProfile)
|
||||
mustBind(w, "selectProfile", a.selectProfile)
|
||||
mustBind(w, "deleteProfile", a.deleteProfile)
|
||||
mustBind(w, "installCore", a.installCore)
|
||||
mustBind(w, "openURL", openURL)
|
||||
mustBind(w, "pingServers", a.pingServers)
|
||||
mustBind(w, "checkUpdate", a.checkUpdate)
|
||||
mustBind(w, "applyUpdate", a.applyUpdate)
|
||||
|
||||
go a.autoCheckUpdate()
|
||||
|
||||
w.SetHtml(appui.IndexHTML)
|
||||
w.Run()
|
||||
}
|
||||
|
||||
func mustBind(w webview2.WebView, name string, fn interface{}) {
|
||||
if err := w.Bind(name, fn); err != nil {
|
||||
fatalDialog("bind %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) getState() (uiState, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
st := a.mgr.Status()
|
||||
cfg := a.mgr.Config()
|
||||
proxy := ""
|
||||
active := cfg.Active
|
||||
if p, err := cfg.ActiveProfile(); err == nil {
|
||||
proxy = p.Proxy
|
||||
active = p.Name
|
||||
}
|
||||
corePath, coreErr := naive.ResolveBinary(a.mgr.BinDir())
|
||||
out := uiState{
|
||||
Connected: st.Connected,
|
||||
Profile: st.Profile,
|
||||
ActiveProfile: active,
|
||||
Protocol: string(st.Protocol),
|
||||
HTTPProxy: st.HTTPProxy,
|
||||
SOCKSProxy: st.SOCKSProxy,
|
||||
SystemProxy: cfg.SystemProxy,
|
||||
Proxy: proxy,
|
||||
CoreReady: coreErr == nil,
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.cfgPath,
|
||||
Profiles: cfg.ListProfiles(),
|
||||
Version: update.CurrentVersion,
|
||||
Update: a.updateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.pings...),
|
||||
}
|
||||
if st.Connected {
|
||||
out.SystemProxy = st.SystemProxy
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("укажите название профиля")
|
||||
}
|
||||
a.mgr.SetSystemProxy(systemProxy)
|
||||
return a.mgr.SaveActiveProfile(name, proxy)
|
||||
}
|
||||
|
||||
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("укажите название профиля")
|
||||
}
|
||||
a.mgr.SetSystemProxy(systemProxy)
|
||||
return a.mgr.SaveProfile(name, proxy)
|
||||
}
|
||||
|
||||
func (a *app) selectProfile(name string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.SetActiveProfile(name)
|
||||
}
|
||||
|
||||
func (a *app) deleteProfile(name string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.DeleteProfile(name)
|
||||
}
|
||||
|
||||
func (a *app) connect() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
|
||||
return err
|
||||
} else if strings.TrimSpace(p.Proxy) == "" {
|
||||
return fmt.Errorf("сначала вставьте ссылку сервера")
|
||||
}
|
||||
|
||||
if _, err := naive.EnsureBinary(a.mgr.BinDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
return a.mgr.Connect(ctx, "")
|
||||
}
|
||||
|
||||
func (a *app) disconnect() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mgr.Disconnect()
|
||||
}
|
||||
|
||||
func (a *app) installCore() (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return naive.EnsureBinary(a.mgr.BinDir())
|
||||
}
|
||||
|
||||
func (a *app) pingServers() ([]netcheck.Result, error) {
|
||||
a.mu.Lock()
|
||||
list := a.mgr.Profiles()
|
||||
a.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out := make([]netcheck.Result, 0, len(list))
|
||||
for _, p := range list {
|
||||
proxy := p.Proxy
|
||||
if proxy == "" {
|
||||
out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"})
|
||||
continue
|
||||
}
|
||||
out = append(out, netcheck.PingProxyURI(ctx, p.Name, proxy))
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.pings = out
|
||||
a.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *app) checkUpdate() (update.Status, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
st, err := update.Check(ctx, update.DefaultManifestURL)
|
||||
a.mu.Lock()
|
||||
if err != nil {
|
||||
st.Current = update.CurrentVersion
|
||||
st.ManifestURL = update.DefaultManifestURL
|
||||
st.Error = err.Error()
|
||||
}
|
||||
a.updateStatus = st
|
||||
a.mu.Unlock()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (a *app) applyUpdate() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
latest, err := update.Apply(ctx, update.DefaultManifestURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.mgr.Disconnect()
|
||||
go func() {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if a.webview != nil {
|
||||
a.webview.Terminate()
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
_ = latest
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) autoCheckUpdate() {
|
||||
time.Sleep(2 * time.Second)
|
||||
_, _ = a.checkUpdate()
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _ = a.checkUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
func openURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch raw {
|
||||
case "https://evilfox.win/", "https://evilfox.win", "http://evilfox.win/", "http://evilfox.win":
|
||||
raw = "https://evilfox.win/"
|
||||
default:
|
||||
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
||||
}
|
||||
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", raw)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func applyWindowIcon(hwnd unsafe.Pointer) {
|
||||
ico := findIconPath()
|
||||
if ico == "" || hwnd == nil {
|
||||
return
|
||||
}
|
||||
pathPtr, err := windows.UTF16PtrFromString(ico)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||
loadImage := user32.NewProc("LoadImageW")
|
||||
sendMessage := user32.NewProc("SendMessageW")
|
||||
const (
|
||||
imageIcon = 1
|
||||
lrLoadFromFile = 0x0010
|
||||
lrDefaultSize = 0x0040
|
||||
wmSetIcon = 0x0080
|
||||
iconSmall = 0
|
||||
iconBig = 1
|
||||
)
|
||||
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
|
||||
if h == 0 {
|
||||
return
|
||||
}
|
||||
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
|
||||
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
|
||||
}
|
||||
|
||||
func findIconPath() string {
|
||||
candidates := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, "assets", "navis.ico"),
|
||||
filepath.Join(dir, "navis.ico"),
|
||||
)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fatalDialog(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
messageBox(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func messageBox(text string) {
|
||||
user32 := syscall.NewLazyDLL("user32.dll")
|
||||
proc := user32.NewProc("MessageBoxW")
|
||||
title, _ := syscall.UTF16PtrFromString("Navis")
|
||||
body, _ := syscall.UTF16PtrFromString(text)
|
||||
proc.Call(0, uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(title)), 0x10) // MB_ICONERROR
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/protocols/naive"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch cmd {
|
||||
case "connect":
|
||||
os.Exit(runConnect(args))
|
||||
case "disconnect":
|
||||
fmt.Fprintln(os.Stderr, "disconnect: use Ctrl+C on a running connect session")
|
||||
os.Exit(2)
|
||||
case "status":
|
||||
fmt.Fprintln(os.Stderr, "status: only available while connect is running in this process")
|
||||
os.Exit(2)
|
||||
case "install-core":
|
||||
os.Exit(runInstallCore(args))
|
||||
case "init":
|
||||
os.Exit(runInit(args))
|
||||
case "probe":
|
||||
os.Exit(runProbe(args))
|
||||
case "set-proxy":
|
||||
os.Exit(runSetProxy(args))
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `VPN client (NaiveProxy) for Windows
|
||||
|
||||
Usage:
|
||||
vpnclient init [-config configs/config.json]
|
||||
vpnclient install-core [-config configs/config.json]
|
||||
vpnclient set-proxy [-config configs/config.json] <share-or-proxy-uri>
|
||||
vpnclient connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe]
|
||||
vpnclient probe [-config configs/config.json] [-profile name] [-url URL]
|
||||
|
||||
Share links like naive+https://user:pass@host:443#name are accepted.
|
||||
|
||||
Architecture:
|
||||
This client runs the official naive.exe from klzgrad/naiveproxy (Chromium
|
||||
network stack) and optionally sets the Windows system HTTP proxy so apps
|
||||
use the tunnel. That is a real NaiveProxy session, not a simulation.
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
func defaultConfigPath() string {
|
||||
return filepath.Join("configs", "config.json")
|
||||
}
|
||||
|
||||
func runInit(args []string) int {
|
||||
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", defaultConfigPath(), "path to write example config")
|
||||
_ = fs.Parse(args)
|
||||
if _, err := os.Stat(*cfgPath); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "refusing to overwrite existing %s\n", *cfgPath)
|
||||
return 1
|
||||
}
|
||||
if err := config.WriteExample(*cfgPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "init: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("wrote %s — edit proxy user/pass/host, then: vpnclient install-core && vpnclient connect\n", *cfgPath)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runInstallCore(args []string) int {
|
||||
fs := flag.NewFlagSet("install-core", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", defaultConfigPath(), "config path (for bin_dir)")
|
||||
binDirFlag := fs.String("bin-dir", "", "override bin directory")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
binDir := *binDirFlag
|
||||
if binDir == "" {
|
||||
if cfg, err := config.Load(*cfgPath); err == nil {
|
||||
resolved, err := config.ResolveBinDir(*cfgPath, cfg.BinDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install-core: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
binDir = resolved
|
||||
} else {
|
||||
binDir = "bin"
|
||||
}
|
||||
}
|
||||
path, err := naive.EnsureBinary(binDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install-core: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("naive core ready: %s\n", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadManager(cfgPath string) (*core.Manager, *config.Config, error) {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mgr, err := core.NewManager(cfgPath, cfg, os.Stderr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return mgr, cfg, nil
|
||||
}
|
||||
|
||||
func runConnect(args []string) int {
|
||||
fs := flag.NewFlagSet("connect", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", defaultConfigPath(), "config path")
|
||||
profile := fs.String("profile", "", "profile name (default: config.active)")
|
||||
noSys := fs.Bool("no-sysproxy", false, "do not change Windows system proxy")
|
||||
doProbe := fs.Bool("probe", false, "verify tunnel with an HTTP request after connect")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
mgr, cfg, err := loadManager(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if *noSys {
|
||||
cfg.SystemProxy = false
|
||||
}
|
||||
|
||||
// Ensure official core is present.
|
||||
if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := mgr.Connect(ctx, *profile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() {
|
||||
if err := mgr.Disconnect(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "disconnect: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
st := mgr.Status()
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(st)
|
||||
fmt.Fprintln(os.Stderr, "connected — press Ctrl+C to disconnect and restore system proxy")
|
||||
|
||||
if *doProbe {
|
||||
pctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
err := mgr.Probe(pctx, "")
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "probe ok")
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
fmt.Fprintln(os.Stderr, "shutting down...")
|
||||
return 0
|
||||
}
|
||||
|
||||
func runProbe(args []string) int {
|
||||
fs := flag.NewFlagSet("probe", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", defaultConfigPath(), "config path")
|
||||
profile := fs.String("profile", "", "profile name")
|
||||
testURL := fs.String("url", "https://www.google.com/generate_204", "URL to fetch via tunnel")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
mgr, cfg, err := loadManager(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
cfg.SystemProxy = false
|
||||
|
||||
if _, err := naive.EnsureBinary(mgr.BinDir()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := mgr.Connect(ctx, *profile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe: connect: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer mgr.Disconnect()
|
||||
|
||||
pctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
if err := mgr.Probe(pctx, *testURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "probe failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("probe ok")
|
||||
return 0
|
||||
}
|
||||
|
||||
func runSetProxy(args []string) int {
|
||||
fs := flag.NewFlagSet("set-proxy", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", defaultConfigPath(), "config path")
|
||||
_ = fs.Parse(args)
|
||||
rest := fs.Args()
|
||||
if len(rest) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: vpnclient set-proxy <uri>")
|
||||
return 2
|
||||
}
|
||||
mgr, _, err := loadManager(*cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "set-proxy: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := mgr.UpdateProxyURI(rest[0]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "set-proxy: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("proxy saved")
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"active": "naive-main",
|
||||
"system_proxy": true,
|
||||
"bin_dir": "bin",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "naive-main",
|
||||
"protocol": "naive",
|
||||
"proxy": "",
|
||||
"listen": [
|
||||
"socks://127.0.0.1:1080",
|
||||
"http://127.0.0.1:1081"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "1.1.2",
|
||||
"notes": "Обновления через git.evilfox.cc",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85",
|
||||
"mandatory": false
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "1.1.2",
|
||||
"notes": "Обновления через git.evilfox.cc",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85",
|
||||
"mandatory": false
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module vpnclient
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sys v0.33.0
|
||||
)
|
||||
|
||||
require github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
@@ -0,0 +1,10 @@
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Хостинг обновлений Navis
|
||||
|
||||
Обновления отдаются из git-репозитория:
|
||||
|
||||
- `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json`
|
||||
- `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe`
|
||||
|
||||
Пример `dist/update.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.1.2",
|
||||
"notes": "Что нового",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "hex",
|
||||
"mandatory": false
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "1.1.2",
|
||||
"notes": "Обновления через git.evilfox.cc",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b6fd14189e777383da7db4793c44568a9d516774f5d9bc11d63843db30dcac85",
|
||||
"mandatory": false
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := filepath.Join("assets", "navis-icon.png")
|
||||
out := filepath.Join("assets", "navis.ico")
|
||||
f, err := os.Open(in)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
src, err := png.Decode(f)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
sizes := []int{16, 32, 48, 64, 128, 256}
|
||||
type entry struct {
|
||||
size int
|
||||
png []byte
|
||||
}
|
||||
var entries []entry
|
||||
for _, size := range sizes {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("navis-%d.png", size))
|
||||
tf, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := png.Encode(tf, dst); err != nil {
|
||||
tf.Close()
|
||||
fatal(err)
|
||||
}
|
||||
tf.Close()
|
||||
b, err := os.ReadFile(tmp)
|
||||
_ = os.Remove(tmp)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
entries = append(entries, entry{size: size, png: b})
|
||||
}
|
||||
|
||||
var blob []byte
|
||||
offset := 6 + 16*len(entries)
|
||||
for _, e := range entries {
|
||||
blob = append(blob, e.png...)
|
||||
}
|
||||
|
||||
buf := make([]byte, offset+len(blob))
|
||||
binary.LittleEndian.PutUint16(buf[0:], 0) // reserved
|
||||
binary.LittleEndian.PutUint16(buf[2:], 1) // icon
|
||||
binary.LittleEndian.PutUint16(buf[4:], uint16(len(entries)))
|
||||
dataOff := offset
|
||||
for i, e := range entries {
|
||||
dir := buf[6+i*16:]
|
||||
w, h := e.size, e.size
|
||||
if w >= 256 {
|
||||
dir[0], dir[1] = 0, 0
|
||||
} else {
|
||||
dir[0], dir[1] = byte(w), byte(h)
|
||||
}
|
||||
dir[2] = 0 // colors
|
||||
dir[3] = 0
|
||||
binary.LittleEndian.PutUint16(dir[4:], 1) // planes
|
||||
binary.LittleEndian.PutUint16(dir[6:], 32)
|
||||
binary.LittleEndian.PutUint32(dir[8:], uint32(len(e.png)))
|
||||
binary.LittleEndian.PutUint32(dir[12:], uint32(dataOff))
|
||||
copy(buf[dataOff:], e.png)
|
||||
dataOff += len(e.png)
|
||||
}
|
||||
if err := os.WriteFile(out, buf, 0o644); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Println("wrote", out)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": { "Major": 1, "Minor": 1, "Patch": 2, "Build": 0 },
|
||||
"ProductVersion": { "Major": 1, "Minor": 1, "Patch": 2, "Build": 0 },
|
||||
"FileFlagsMask": "3f",
|
||||
"FileFlags": "00",
|
||||
"FileOS": "40004",
|
||||
"FileType": "01",
|
||||
"FileSubType": "00"
|
||||
},
|
||||
"StringFileInfo": {
|
||||
"CompanyName": "Navis",
|
||||
"FileDescription": "Navis — NaiveProxy client",
|
||||
"FileVersion": "1.1.2.0",
|
||||
"InternalName": "Navis",
|
||||
"OriginalFilename": "Navis.exe",
|
||||
"ProductName": "Navis",
|
||||
"ProductVersion": "1.1.2.0"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
"Translation": {
|
||||
"LangID": "0409",
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
},
|
||||
"IconPath": "assets/navis.ico"
|
||||
}
|
||||
Reference in New Issue
Block a user