Restore orphaned system proxy after crash, require update SHA-256, add macOS /api auth token, fix UDP ping false positives, HTTPS-only subscriptions, and keep the UI responsive during connect. Co-authored-by: Cursor <cursoragent@cursor.com>
544 lines
14 KiB
Go
544 lines
14 KiB
Go
package apphost
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"vpnclient/internal/appui"
|
|
"vpnclient/internal/config"
|
|
"vpnclient/internal/core"
|
|
"vpnclient/internal/netcheck"
|
|
"vpnclient/internal/protocols/awg"
|
|
"vpnclient/internal/protocols/hysteria2"
|
|
"vpnclient/internal/protocols/naive"
|
|
"vpnclient/internal/protocols/xray"
|
|
"vpnclient/internal/update"
|
|
)
|
|
|
|
// App is the GUI controller shared by Windows WebView and macOS HTTP UI.
|
|
type App struct {
|
|
mu sync.Mutex
|
|
Mgr *core.Manager
|
|
CfgPath string
|
|
LogBuf *bytes.Buffer
|
|
UpdateStatus update.Status
|
|
Pings []netcheck.Result
|
|
OpenURL func(string) error
|
|
OnAfterUpdate func()
|
|
APIToken string
|
|
}
|
|
|
|
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"`
|
|
Subscription string `json:"subscription_url"`
|
|
Hy2 core.Hy2Options `json:"hy2"`
|
|
}
|
|
|
|
type PingBestResult struct {
|
|
Pings []netcheck.Result `json:"pings"`
|
|
BestName string `json:"best_name,omitempty"`
|
|
BestMs int64 `json:"best_ms,omitempty"`
|
|
Selected bool `json:"selected"`
|
|
Connected bool `json:"connected"`
|
|
}
|
|
|
|
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
|
return &App{
|
|
Mgr: mgr,
|
|
CfgPath: cfgPath,
|
|
LogBuf: logBuf,
|
|
UpdateStatus: update.Status{
|
|
Current: update.DisplayVersion(),
|
|
ManifestURL: update.DefaultManifestURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
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 := ""
|
|
coreReady := false
|
|
if p, err := cfg.ActiveProfile(); err == nil {
|
|
switch p.Protocol {
|
|
case config.ProtocolHysteria2:
|
|
if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
|
corePath, coreReady = path, true
|
|
}
|
|
case config.ProtocolAWG:
|
|
if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
|
corePath, coreReady = path, true
|
|
}
|
|
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
|
|
if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
|
corePath, coreReady = path, true
|
|
}
|
|
default:
|
|
if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
|
corePath, coreReady = path, true
|
|
}
|
|
}
|
|
} else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
|
|
corePath, coreReady = path, true
|
|
}
|
|
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: coreReady,
|
|
CorePath: corePath,
|
|
ConfigPath: a.CfgPath,
|
|
Profiles: config.RedactProfileList(cfg.ListProfiles()),
|
|
Version: update.DisplayVersion(),
|
|
Update: a.UpdateStatus,
|
|
Pings: append([]netcheck.Result(nil), a.Pings...),
|
|
Subscription: cfg.SubscriptionURL,
|
|
Hy2: a.Mgr.ActiveHy2Options(),
|
|
}
|
|
if out.Protocol == "" {
|
|
if p, err := cfg.ActiveProfile(); err == nil {
|
|
out.Protocol = string(p.Protocol)
|
|
}
|
|
}
|
|
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 { return a.ConnectProfile("") }
|
|
|
|
func (a *App) ConnectProfile(name string) error {
|
|
a.mu.Lock()
|
|
name = strings.TrimSpace(name)
|
|
st := a.Mgr.Status()
|
|
if st.Connected {
|
|
if name == "" || st.Profile == name {
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
a.mu.Unlock()
|
|
if err := a.Mgr.Disconnect(); err != nil {
|
|
return err
|
|
}
|
|
a.mu.Lock()
|
|
}
|
|
if name != "" {
|
|
if err := a.Mgr.SetActiveProfile(name); err != nil {
|
|
a.mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
|
a.mu.Unlock()
|
|
return err
|
|
} else if strings.TrimSpace(p.Proxy) == "" {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("сначала вставьте ссылку сервера")
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
|
|
if _, err := a.Mgr.EnsureCore(""); 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 {
|
|
return a.Mgr.Disconnect()
|
|
}
|
|
|
|
func (a *App) InstallCore() (string, error) {
|
|
paths, err := a.Mgr.EnsureAllCores()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts := make([]string, 0, len(paths))
|
|
for k, v := range paths {
|
|
parts = append(parts, k+": "+v)
|
|
}
|
|
return strings.Join(parts, " | "), nil
|
|
}
|
|
|
|
func (a *App) SaveHy2(opts core.Hy2Options) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.Mgr.SaveHy2Options(opts)
|
|
}
|
|
|
|
func (a *App) ImportSubscription(rawURL string) (int, error) {
|
|
return a.Mgr.ImportSubscription(rawURL)
|
|
}
|
|
|
|
func (a *App) PingServers() ([]netcheck.Result, error) {
|
|
return a.runPings()
|
|
}
|
|
|
|
func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
|
|
pings, err := a.runPings()
|
|
out := PingBestResult{Pings: pings}
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
best, ok := netcheck.BestOK(pings)
|
|
if !ok {
|
|
return out, fmt.Errorf("нет доступных серверов")
|
|
}
|
|
out.BestName = best.Name
|
|
out.BestMs = best.Ms
|
|
|
|
a.mu.Lock()
|
|
st := a.Mgr.Status()
|
|
alreadyBest := st.Connected && st.Profile == best.Name
|
|
if st.Connected && !alreadyBest {
|
|
a.mu.Unlock()
|
|
if err := a.Mgr.Disconnect(); err != nil {
|
|
return out, err
|
|
}
|
|
a.mu.Lock()
|
|
}
|
|
if !alreadyBest {
|
|
if err := a.Mgr.SetActiveProfile(best.Name); err != nil {
|
|
a.mu.Unlock()
|
|
return out, err
|
|
}
|
|
}
|
|
out.Selected = true
|
|
if !autoConnect {
|
|
a.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
if alreadyBest {
|
|
a.mu.Unlock()
|
|
out.Connected = true
|
|
return out, nil
|
|
}
|
|
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
|
|
a.mu.Unlock()
|
|
return out, err
|
|
} else if strings.TrimSpace(p.Proxy) == "" {
|
|
a.mu.Unlock()
|
|
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
if _, err := a.Mgr.EnsureCore(""); err != nil {
|
|
return out, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
if err := a.Mgr.Connect(ctx, ""); err != nil {
|
|
return out, err
|
|
}
|
|
out.Connected = true
|
|
return out, nil
|
|
}
|
|
|
|
func (a *App) runPings() ([]netcheck.Result, error) {
|
|
a.mu.Lock()
|
|
list := a.Mgr.Profiles()
|
|
a.mu.Unlock()
|
|
|
|
targets := make([]netcheck.Target, 0, len(list))
|
|
for _, p := range list {
|
|
targets = append(targets, netcheck.Target{
|
|
Name: p.Name,
|
|
Protocol: config.Protocol(p.Protocol),
|
|
Proxy: p.Proxy,
|
|
})
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
defer cancel()
|
|
out := netcheck.PingAll(ctx, targets)
|
|
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.DisplayVersion()
|
|
st.ManifestURL = update.DefaultManifestURL
|
|
st.Error = err.Error()
|
|
}
|
|
a.UpdateStatus = st
|
|
a.mu.Unlock()
|
|
return st, nil
|
|
}
|
|
|
|
func (a *App) ApplyUpdate() (string, error) {
|
|
// Explicit user action only — never called from AutoCheckUpdate.
|
|
st, err := a.CheckUpdate()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !st.Available {
|
|
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
|
|
}
|
|
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()
|
|
if a.OnAfterUpdate != nil {
|
|
a.OnAfterUpdate()
|
|
} else {
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
os.Exit(0)
|
|
}()
|
|
}
|
|
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
|
|
}
|
|
|
|
func (a *App) AutoCheckUpdate() {
|
|
// Only check and populate status for the banner — never auto-apply.
|
|
// Auto-apply previously caused endless download→relaunch loops on Mac/Windows.
|
|
time.Sleep(3 * time.Second)
|
|
_, _ = a.CheckUpdate()
|
|
}
|
|
|
|
func (a *App) OpenShopURL(raw string) error {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fmt.Errorf("пустая ссылка")
|
|
}
|
|
lower := strings.ToLower(raw)
|
|
switch {
|
|
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
|
|
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
|
|
raw = "https://evilfox.win/"
|
|
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
|
|
default:
|
|
return fmt.Errorf("разрешена только ссылка evilfox.win")
|
|
}
|
|
if a.OpenURL != nil {
|
|
return a.OpenURL(raw)
|
|
}
|
|
return fmt.Errorf("openURL не настроен")
|
|
}
|
|
|
|
// Handler serves embedded UI and JSON-RPC style /api/<method> endpoints.
|
|
func (a *App) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
html := appui.IndexHTML
|
|
if a.APIToken != "" {
|
|
// Inject session token for the HTTP bridge (macOS).
|
|
html = strings.Replace(html, "</head>",
|
|
"<script>window.__NAVIS_TOKEN__="+jsonString(a.APIToken)+";</script></head>", 1)
|
|
}
|
|
_, _ = io.WriteString(w, html)
|
|
})
|
|
mux.HandleFunc("/api/", a.handleAPI)
|
|
return mux
|
|
}
|
|
|
|
func jsonString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if a.APIToken != "" {
|
|
tok := r.Header.Get("X-Navis-Token")
|
|
if tok == "" {
|
|
tok = r.URL.Query().Get("t")
|
|
}
|
|
if tok != a.APIToken {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, "/api/")
|
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
var req struct {
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
_ = json.Unmarshal(body, &req)
|
|
|
|
result, err := a.dispatch(name, req.Args)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
if err != nil {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"result": result})
|
|
}
|
|
|
|
func arg[T any](args []json.RawMessage, i int, def T) T {
|
|
if i >= len(args) {
|
|
return def
|
|
}
|
|
var v T
|
|
if err := json.Unmarshal(args[i], &v); err != nil {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
|
|
switch name {
|
|
case "getState":
|
|
return a.GetState()
|
|
case "connect":
|
|
return nil, a.Connect()
|
|
case "disconnect":
|
|
return nil, a.Disconnect()
|
|
case "connectProfile":
|
|
return nil, a.ConnectProfile(arg(args, 0, ""))
|
|
case "saveProfile":
|
|
return nil, a.SaveProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
|
|
case "createProfile":
|
|
return nil, a.CreateProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
|
|
case "selectProfile":
|
|
return nil, a.SelectProfile(arg(args, 0, ""))
|
|
case "deleteProfile":
|
|
return nil, a.DeleteProfile(arg(args, 0, ""))
|
|
case "installCore":
|
|
return a.InstallCore()
|
|
case "openURL":
|
|
return nil, a.OpenShopURL(arg(args, 0, ""))
|
|
case "pingServers":
|
|
return a.PingServers()
|
|
case "pingBest":
|
|
return a.PingBest(arg(args, 0, false))
|
|
case "checkUpdate":
|
|
return a.CheckUpdate()
|
|
case "applyUpdate":
|
|
return a.ApplyUpdate()
|
|
case "saveHy2":
|
|
var opts core.Hy2Options
|
|
if len(args) > 0 {
|
|
_ = json.Unmarshal(args[0], &opts)
|
|
}
|
|
return nil, a.SaveHy2(opts)
|
|
case "importSubscription":
|
|
return a.ImportSubscription(arg(args, 0, ""))
|
|
case "quit":
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
_ = a.Mgr.Disconnect()
|
|
os.Exit(0)
|
|
}()
|
|
return "ok", nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown method %s", name)
|
|
}
|
|
}
|
|
|
|
// ListenLocal binds 127.0.0.1:0, generates an API token, and returns listener + URL.
|
|
func ListenLocal() (net.Listener, string, string, error) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
tok, err := randomToken(24)
|
|
if err != nil {
|
|
_ = ln.Close()
|
|
return nil, "", "", err
|
|
}
|
|
url := fmt.Sprintf("http://%s/", ln.Addr().String())
|
|
return ln, url, tok, nil
|
|
}
|
|
|
|
func randomToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|