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()) }