Release 2.7.3.1: harden proxy recovery, updates and local API.

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>
This commit is contained in:
M4
2026-07-29 18:48:54 +03:00
co-authored by Cursor
parent dc700f2bac
commit 041cbb1250
32 changed files with 346 additions and 108 deletions
+68 -18
View File
@@ -3,6 +3,8 @@ package apphost
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -26,14 +28,15 @@ import (
// 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
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 {
@@ -124,7 +127,7 @@ func (a *App) GetState() (UIState, error) {
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.CfgPath,
Profiles: cfg.ListProfiles(),
Profiles: config.RedactProfileList(cfg.ListProfiles()),
Version: update.DisplayVersion(),
Update: a.UpdateStatus,
Pings: append([]netcheck.Result(nil), a.Pings...),
@@ -180,28 +183,35 @@ func (a *App) Connect() error { return a.ConnectProfile("") }
func (a *App) ConnectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
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
}
@@ -215,8 +225,6 @@ func (a *App) Disconnect() error {
}
func (a *App) InstallCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.Mgr.EnsureAllCores()
if err != nil {
return "", err
@@ -256,32 +264,40 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.BestMs = best.Ms
a.mu.Lock()
defer a.mu.Unlock()
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
}
@@ -394,17 +410,38 @@ func (a *App) Handler() http.Handler {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, appui.IndexHTML)
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 {
@@ -482,12 +519,25 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
}
}
// ListenLocal binds 127.0.0.1:0 and returns listener + URL.
func ListenLocal() (net.Listener, string, error) {
// 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
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, nil
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
}