diff options
| author | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
|---|---|---|
| committer | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
| commit | 3d51aa455006903345f554a2dd90034993796114 (patch) | |
| tree | 62a7be2faf047f5eb7886feebc3b815556f03d7f /cmd/client/app.go | |
| download | vpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2 vpnem-3d51aa455006903345f554a2dd90034993796114.zip | |
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5)
- Smart load balancing via recommendation API
- Windows/Linux client (Go + Wails + sing-box)
- Server API with RealIP detection and connection tracking
- Auto-deployment via vpnui control plane
- Silent Windows installer with UAC elevation
- Load-based server recommendation (no sticky sessions)
- Best Server one-click connection workflow
Diffstat (limited to 'cmd/client/app.go')
| -rw-r--r-- | cmd/client/app.go | 729 |
1 files changed, 729 insertions, 0 deletions
diff --git a/cmd/client/app.go b/cmd/client/app.go new file mode 100644 index 0000000..6d26318 --- /dev/null +++ b/cmd/client/app.go @@ -0,0 +1,729 @@ +package main + +import ( + "context" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "os/exec" + "runtime" + "time" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "vpnem/internal/config" + "vpnem/internal/engine" + "vpnem/internal/models" + "vpnem/internal/state" + syncpkg "vpnem/internal/sync" +) + +const Version = "2.0.11" + +// App is the Wails backend. +type App struct { + ctx context.Context + engine *engine.Engine + watchdog *engine.Watchdog + fetcher *syncpkg.Fetcher + updater *syncpkg.Updater + state *state.Store + log *engine.RingLog + + catalog *models.CatalogV2 + servers []models.Server + ruleSets []models.RuleSet + policy *models.RoutingPolicy + + recommendedServerIP string + recommendedNodeID string + recommendReason string + studioClients int +} + +// NewApp creates a new App instance. +func NewApp(dataDir, apiURL string) *App { + eng := engine.New(dataDir) + fetcher := syncpkg.NewFetcher(apiURL) + rl := engine.NewRingLog(200, dataDir) + return &App{ + engine: eng, + watchdog: engine.NewWatchdog(eng, engine.DefaultWatchdogConfig()), + fetcher: fetcher, + updater: syncpkg.NewUpdater(fetcher, Version, dataDir), + state: state.NewStore(dataDir), + log: rl, + } +} + +// startup is called when the app starts. Must not block — Wails UI won't render until this returns. +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + _ = a.state.Load() + a.logEvent("vpnem " + Version + " started") + + // Sync + recommendation synchronously — UI won't render until done + if err := a.Sync(); err != nil { + a.logEvent("initial sync failed: " + err.Error()) + a.reportError("initial sync failed: " + err.Error()) + } else { + a.logEvent("initial sync ok") + } + + // Fetch recommendation synchronously — blocks until ready + a.fetchRecommendation() + + // Periodic refresh in background + go func() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := a.Sync(); err != nil { + a.logEvent("sync failed: " + err.Error()) + } + a.fetchRecommendation() + case <-ctx.Done(): + return + } + } + }() +} + +// shutdown is called when the app closes. +func (a *App) shutdown(ctx context.Context) { + a.logEvent("shutting down") + a.watchdog.StopWatching() + if err := a.engine.Stop(); err != nil { + a.logEvent("engine stop error: " + err.Error()) + } + // Fallback: kill any orphaned sing-box + killSingbox() + _ = a.state.Save() + a.logEvent("shutdown complete") + a.log.Close() +} + +func killSingbox() { + if runtime.GOOS == "windows" { + exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run() + } else { + exec.Command("pkill", "-f", "sing-box").Run() + } +} + +func (a *App) logEvent(msg string) { + line := time.Now().Format("15:04:05") + " " + msg + a.log.Add(line) + log.Println(msg) +} + +// --- Wails bindings --- + +// Sync fetches servers and rulesets from the API. +func (a *App) Sync() error { + catalog, err := a.fetcher.FetchCatalog() + if err != nil { + return fmt.Errorf("sync catalog: %w", err) + } + a.catalog = catalog + a.servers = syncpkg.CatalogToServers(catalog) + + rsResp, err := a.fetcher.FetchRuleSets() + if err != nil { + return fmt.Errorf("sync rulesets: %w", err) + } + + dataDir := a.state.DataDir() + a.ruleSets, err = a.fetcher.DownloadRuleSets(rsResp.RuleSets, dataDir) + if err != nil { + return fmt.Errorf("download rule-sets: %w", err) + } + + policy, err := a.fetcher.FetchRoutingPolicy() + if err != nil { + return fmt.Errorf("sync routing policy: %w", err) + } + a.policy = policy + + a.state.SetLastSync(time.Now()) + _ = a.state.Save() + a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets, routing policy %s", len(a.servers), len(a.ruleSets), a.policy.Version)) + + // Notify frontend to refresh + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "synced") + } + return nil +} + +func (a *App) applyProfile(serverTag, modeName string, logAction string) error { + server := a.findServer(serverTag) + if server == nil { + return fmt.Errorf("server not found: %s", serverTag) + } + mode := config.ModeByName(modeName) + if mode == nil { + return fmt.Errorf("mode not found: %s", modeName) + } + + serverIPs := syncpkg.ServerIPs(a.servers) + activeRuleSets := a.activeRuleSets(*mode) + customBypass := a.state.GetCustomBypass() + localProxyPort, err := engine.ResolveLocalProxyPort() + if err != nil { + return fmt.Errorf("allocate local proxy port: %w", err) + } + + a.logEvent(logAction + ": " + serverTag + " [" + modeName + "]") + + // Flush DNS cache before applying a new profile (Windows caches poisoned responses). + flushDNS() + + if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy); err != nil { + a.logEvent(logAction + " failed: " + err.Error()) + return err + } + + a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy) + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + a.state.SetLocalProxyPort(localProxyPort) + _ = a.state.Save() + a.logEvent("connected: " + serverTag) + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "connected", serverTag) + } + + // Report connection to server for recommendation tracking + go a.ReportConnection(serverTag, server.Server, a.extractNodeID(serverTag)) + + go a.validateConnection() + return nil +} + +// Connect starts the VPN with the given server and mode. +func (a *App) Connect(serverTag, modeName string) error { + return a.applyProfile(serverTag, modeName, "connecting") +} + +// ApplyProfile switches the active server/mode live when connected. +// If the engine is not running yet, it only persists the selection for the next connect. +func (a *App) ApplyProfile(serverTag, modeName string) error { + server := a.findServer(serverTag) + if server == nil { + return fmt.Errorf("server not found: %s", serverTag) + } + mode := config.ModeByName(modeName) + if mode == nil { + return fmt.Errorf("mode not found: %s", modeName) + } + + current := a.state.Get() + if !a.engine.IsRunning() { + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + _ = a.state.Save() + a.logEvent("profile selected: " + serverTag + " [" + modeName + "]") + return nil + } + + if current.SelectedServer == serverTag && current.SelectedMode == modeName { + return nil + } + + a.watchdog.StopWatching() + if err := a.applyProfile(serverTag, modeName, "switching profile"); err != nil { + return err + } + + a.state.SetServer(serverTag) + a.state.SetMode(modeName) + _ = a.state.Save() + return nil +} + +func flushDNS() { + if runtime.GOOS == "windows" { + exec.Command("ipconfig", "/flushdns").Run() + log.Println("DNS cache flushed") + } +} + +func (a *App) validateConnection() { + time.Sleep(3 * time.Second) + if !a.engine.IsRunning() { + return + } + + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 10*time.Second) + if err != nil { + a.logEvent("validation setup failed — " + err.Error()) + return + } + + mode := config.ModeByName(a.state.Get().SelectedMode) + requiresExitIP := mode != nil && engine.ModeRequiresExitIP(*mode) + + ip := getExitIP(client) + switch { + case ip != "": + a.logEvent("exit IP: " + ip) + case requiresExitIP: + a.logEvent("WARNING: could not verify exit IP") + default: + a.logEvent("validation: exit IP skipped for direct-final mode") + } + + statusCode, err := engine.ProbeBlockedSite(localProxyPort, engine.DefaultBlockedSiteProbeURL, 10*time.Second) + if err == nil { + a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", statusCode)) + } else { + a.logEvent("validation: rutracker.org FAILED — " + err.Error()) + } +} + +// Disconnect stops the VPN and clears system proxy. +func (a *App) Disconnect() error { + // Report disconnect before stopping + if a.engine.IsRunning() { + st := a.state.Get() + server := a.findServer(st.SelectedServer) + if server != nil { + nodeID := a.extractNodeID(st.SelectedServer) + go a.ReportDisconnect(server.Server, nodeID) + } + } + + a.watchdog.StopWatching() + clearSystemProxy() + a.logEvent("disconnected") + return a.engine.Stop() +} + +// SetSystemProxy sets Windows system SOCKS5 proxy directly (no TUN needed). +// Fallback for when TUN/sing-box doesn't work with browser. +func (a *App) SetSystemProxy(serverTag string) error { + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + if !a.engine.IsRunning() || localProxyPort == 0 { + return fmt.Errorf("connect first to start the local proxy") + } + addr := fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort) + if runtime.GOOS == "windows" { + // Route Windows system proxy through the local SOCKS5 inbound. + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f").Run() + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyServer", "/t", "REG_SZ", "/d", "socks="+addr, "/f").Run() + a.logEvent("system proxy set to local SOCKS5 inbound: " + addr) + } + return nil +} + +func clearSystemProxy() { + if runtime.GOOS == "windows" { + exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`, + "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f").Run() + log.Println("system proxy cleared") + } +} + +// GetServers returns the server list grouped by region. +func (a *App) GetServers() []models.Server { + if a.servers == nil { + return []models.Server{} + } + return a.servers +} + +// GetRecommendedServerTag returns the tag of the recommended MULTI server. +// Only MULTI tags are returned (vless-reality + hysteria2). SOCKS5-only tags are never recommended. +// If the recommended IP doesn't match any MULTI server, falls back to first MULTI. +func (a *App) GetRecommendedServerTag() string { + // If we have a recommended IP, try to find a MULTI server for it + if a.recommendedServerIP != "" && len(a.servers) > 0 { + // Find MULTI server matching recommended IP + for _, s := range a.servers { + if s.Server == a.recommendedServerIP && isMultiServer(s) { + a.logEvent("GetRecommendedServerTag: MATCH " + s.Tag) + return s.Tag + } + } + a.logEvent("GetRecommendedServerTag: no MULTI match for IP " + a.recommendedServerIP) + } + + // Fallback: pick first MULTI server from the list + for _, s := range a.servers { + if isMultiServer(s) { + a.logEvent("GetRecommendedServerTag: fallback to " + s.Tag) + return s.Tag + } + } + + a.logEvent("GetRecommendedServerTag: no MULTI servers found") + return "" +} + +// isMultiServer checks if a server is a MULTI node (split TCP/UDP routing). +func isMultiServer(s models.Server) bool { + return s.Type == "multi" || len(s.Companions) > 0 +} + +// IsServerRecommended checks if a given server tag matches the recommendation. +func (a *App) IsServerRecommended(tag string) bool { + recTag := a.GetRecommendedServerTag() + return recTag != "" && tag == recTag +} + +// GetRecommendationReason returns the human-readable reason for the recommendation. +func (a *App) GetRecommendationReason() string { + return a.recommendReason +} + +// GetCatalog returns the current canonical server catalog. +func (a *App) GetCatalog() *models.CatalogV2 { + if a.catalog == nil { + return &models.CatalogV2{Version: "uninitialized", Nodes: []models.CatalogNode{}} + } + return a.catalog +} + +// GetModes returns all available mode names. +func (a *App) GetModes() []string { + return config.ModeNames() +} + +// GetStatus returns the current connection status. +func (a *App) GetStatus() map[string]any { + st := a.state.Get() + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = st.LocalProxyPort + } + return map[string]any{ + "connected": a.engine.IsRunning(), + "server": st.SelectedServer, + "mode": st.SelectedMode, + "lastSync": st.LastSync, + "autoConnect": st.AutoConnect, + "localProxyHost": config.LocalProxyHost, + "localProxyPort": localProxyPort, + "localProxyURL": fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort), + "localProxyScheme": "socks5", + } +} + +// GetExitIP checks the actual exit IP through the proxy. +func (a *App) GetExitIP() string { + localProxyPort := a.engine.LocalProxyPort() + if localProxyPort == 0 { + localProxyPort = a.state.Get().LocalProxyPort + } + client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second) + if err != nil { + return "" + } + return getExitIP(client) +} + +func getExitIP(client *http.Client) string { + resp, err := client.Get("http://ifconfig.me/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + buf := make([]byte, 64) + n, _ := resp.Body.Read(buf) + return string(buf[:n]) +} + +// SetAutoConnect updates the auto-connect setting. +func (a *App) SetAutoConnect(v bool) { + a.state.SetAutoConnect(v) + _ = a.state.Save() +} + +// GetRuleSets returns all rule-sets with their enabled status. +func (a *App) GetRuleSets() []map[string]any { + result := make([]map[string]any, 0) + for _, rs := range a.ruleSets { + enabled := !rs.Optional || a.state.IsRuleSetEnabled(rs.Tag) + result = append(result, map[string]any{ + "tag": rs.Tag, + "description": rs.Description, + "type": rs.Type, + "optional": rs.Optional, + "enabled": enabled, + }) + } + return result +} + +// SetRuleSetEnabled enables or disables an optional rule-set. +func (a *App) SetRuleSetEnabled(tag string, enabled bool) { + a.state.SetRuleSetEnabled(tag, enabled) + _ = a.state.Save() +} + +// GetBypassProcesses returns default + custom bypass processes. +func (a *App) GetBypassProcesses() map[string]any { + policy := config.EffectiveRoutingPolicy(a.policy) + return map[string]any{ + "default": policy.AlwaysDirectProcesses, + "custom": a.state.GetCustomBypass(), + } +} + +// AddBypassProcess adds a custom bypass process. +func (a *App) AddBypassProcess(name string) { + current := a.state.GetCustomBypass() + for _, p := range current { + if p == name { + return + } + } + a.state.SetCustomBypass(append(current, name)) + _ = a.state.Save() +} + +// RemoveBypassProcess removes a custom bypass process. +func (a *App) RemoveBypassProcess(name string) { + current := a.state.GetCustomBypass() + var filtered []string + for _, p := range current { + if p != name { + filtered = append(filtered, p) + } + } + a.state.SetCustomBypass(filtered) + _ = a.state.Save() +} + +// MeasureLatency pings all servers and returns sorted results. +func (a *App) MeasureLatency() []syncpkg.LatencyResult { + a.logEvent("measuring latency...") + results := syncpkg.MeasureLatency(a.servers, 3*time.Second) + for _, r := range results { + if r.Latency >= 0 { + a.logEvent(fmt.Sprintf(" %s: %dms", r.Tag, r.Latency)) + } + } + return results +} + +// GetLogs returns the last N log lines. +func (a *App) GetLogs() []string { + return a.log.Lines() +} + +// GetGeneratedConfig returns the current sing-box config JSON for diagnostics. +func (a *App) GetGeneratedConfig() string { + path := a.engine.ConfigPath() + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +// CheckUpdate checks if a new version is available. +func (a *App) CheckUpdate() (*syncpkg.UpdateInfo, error) { + return a.updater.Check() +} + +// DownloadUpdate downloads the new binary. Returns "restart_pending" on success. +// The caller should then shut down Wails gracefully — the OS will restart the app. +func (a *App) DownloadUpdate() (string, error) { + result, err := a.updater.Download() + if err != nil { + a.logEvent("update download failed: " + err.Error()) + return "", err + } + if result == "restart_pending" { + a.logEvent("update installed, restarting...") + // Shut down Wails gracefully — the OS Scheduled Task will relaunch + go func() { + time.Sleep(2 * time.Second) + wailsRuntime.Quit(a.ctx) + }() + } + return result, nil +} + +// RandomNLServer picks a random non-RU server tag. +func (a *App) RandomNLServer() string { + var candidates []string + for _, s := range a.servers { + if s.Region != "RU" { + candidates = append(candidates, s.Tag) + } + } + if len(candidates) == 0 { + return "" + } + return candidates[rand.Intn(len(candidates))] +} + +// fetchRecommendation fetches a recommended server from the API. +// Server auto-detects client real IP from X-Forwarded-For header. +func (a *App) fetchRecommendation() { + a.logEvent("fetchRecommendation: starting") + + // Check if we have a recent recommendation + recServer, recNodeID, recTime := a.state.GetRecommendation() + if recServer != "" && time.Since(recTime) < 15*time.Minute { + a.recommendedServerIP = recServer + a.recommendedNodeID = recNodeID + a.recommendReason = "cached" + a.logEvent("fetchRecommendation: using cached " + recServer) + return + } + + // Server detects client IP via X-Forwarded-For + rec, err := a.fetcher.GetRecommendation() + if err != nil { + a.logEvent("fetchRecommendation: error — " + err.Error()) + // Fallback: keep stale recommendation if available + if recServer != "" { + a.recommendedServerIP = recServer + a.recommendedNodeID = recNodeID + a.recommendReason = "stale (server unreachable)" + a.logEvent("fetchRecommendation: using stale " + recServer) + } + return + } + + if rec.RecommendedServerIP == "" { + a.logEvent("fetchRecommendation: empty recommendation from server") + return + } + + a.recommendedServerIP = rec.RecommendedServerIP + a.recommendedNodeID = rec.RecommendedNodeID + a.recommendReason = rec.Reason + if rec.IsRebalance { + a.recommendReason += " (ребалансировка)" + } + + a.state.SetRecommendation(rec.RecommendedServerIP, rec.RecommendedNodeID) + _ = a.state.Save() + + a.logEvent("fetchRecommendation: set " + rec.RecommendedServerIP + " — " + rec.Reason) + if rec.LoadInfo != "" { + a.logEvent(" load: " + rec.LoadInfo) + } +} + +// GetRecommendation returns the current recommendation data. +func (a *App) GetRecommendation() map[string]any { + return map[string]any{ + "server_ip": a.recommendedServerIP, + "node_id": a.recommendedNodeID, + "reason": a.recommendReason, + "studio_clients": a.studioClients, + } +} + +// ReportConnection sends a connect notification to the server. +// Server auto-detects client real IP from X-Forwarded-For. +func (a *App) ReportConnection(serverTag, serverIP, nodeID string) { + a.logEvent("report connect: " + serverTag + " → " + serverIP) + + resp, err := a.fetcher.ReportConnect(serverIP, nodeID) + if err != nil { + a.logEvent("report connect error: " + err.Error()) + return + } + + // Update recommendation if server returned a different one + if resp.RecommendedServerIP != "" && resp.RecommendedServerIP != a.recommendedServerIP { + old := a.recommendedServerIP + a.recommendedServerIP = resp.RecommendedServerIP + a.recommendedNodeID = resp.RecommendedNodeID + a.recommendReason = resp.Reason + if resp.IsRebalance { + a.recommendReason += " (ребалансировка)" + } + a.state.SetRecommendation(resp.RecommendedServerIP, resp.RecommendedNodeID) + _ = a.state.Save() + a.logEvent("recommendation updated: " + old + " → " + resp.RecommendedServerIP) + } +} + +// ReportDisconnect notifies the server of disconnection. +func (a *App) ReportDisconnect(serverIP, nodeID string) { + a.logEvent("report disconnect: " + serverIP) + if err := a.fetcher.ReportDisconnect(serverIP, nodeID); err != nil { + a.logEvent("report disconnect error: " + err.Error()) + } +} + +func (a *App) extractNodeID(serverTag string) string { + // Tags like "nl-198-multi", "nl-198-vless-reality", "nl-198-socks5" -> "nl-198" + for _, suffix := range []string{"-multi", "-vless-reality", "-vless", "-vmess", "-shadowsocks", "-hysteria2", "-socks5", "-socks"} { + if len(serverTag) > len(suffix) && serverTag[len(serverTag)-len(suffix):] == suffix { + return serverTag[:len(serverTag)-len(suffix)] + } + } + return serverTag +} + +func (a *App) findServer(tag string) *models.Server { + for _, s := range a.servers { + if s.Tag == tag { + return &s + } + } + return nil +} + +// activeRuleSets returns rule-sets for the mode PLUS domain rule-sets +// always needed for DNS anti-poisoning (refilter-domains etc). +func (a *App) activeRuleSets(mode config.Mode) []models.RuleSet { + needed := make(map[string]bool) + + // Rule-sets referenced by route rules + for _, r := range mode.Rules { + for _, tag := range r.RuleSet { + needed[tag] = true + } + } + + // Always include domain-type rule-sets for DNS rules + // (prevents ISP DNS poisoning for blocked domains) + for _, rs := range a.ruleSets { + if !rs.Optional && rs.Type == "domain" { + needed[rs.Tag] = true + } + } + + var result []models.RuleSet + for _, rs := range a.ruleSets { + if needed[rs.Tag] { + result = append(result, rs) + } + } + return result +} + +func (a *App) reportError(msg string) { + if a.fetcher == nil { + return + } + osName := "linux" + if runtime.GOOS == "windows" { + osName = "windows" + } + go a.fetcher.ReportError(Version, osName, a.log.Lines()) +} |
