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.6" // 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 servers []models.Server ruleSets []models.RuleSet } // 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 in background, no auto-connect go func() { if err := a.Sync(); err != nil { a.logEvent("initial sync failed: " + err.Error()) } else { a.logEvent("initial sync ok") } // Periodic sync 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()) } 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 { serversResp, err := a.fetcher.FetchServers() if err != nil { return fmt.Errorf("sync servers: %w", err) } a.servers = serversResp.Servers rsResp, err := a.fetcher.FetchRuleSets() if err != nil { return fmt.Errorf("sync rulesets: %w", err) } a.ruleSets = rsResp.RuleSets a.state.SetLastSync(time.Now()) _ = a.state.Save() a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets", len(a.servers), len(a.ruleSets))) // Notify frontend to refresh if a.ctx != nil { wailsRuntime.EventsEmit(a.ctx, "synced") } return nil } // Connect starts the VPN with the given server and mode. func (a *App) Connect(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) } serverIPs := syncpkg.ServerIPs(a.servers) activeRuleSets := a.activeRuleSets(*mode) customBypass := a.state.GetCustomBypass() a.logEvent("connecting: " + serverTag + " [" + modeName + "]") // Flush DNS cache before connecting (Windows caches poisoned responses) flushDNS() if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass); err != nil { a.logEvent("connect failed: " + err.Error()) return err } a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs) a.state.SetServer(serverTag) a.state.SetMode(modeName) _ = a.state.Save() a.logEvent("connected: " + serverTag) // Validate connection in background go a.validateConnection() 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 } // Check exit IP ip := a.GetExitIP() if ip != "" { a.logEvent("exit IP: " + ip) } else { a.logEvent("WARNING: could not verify exit IP") } // Check blocked site client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Head("https://rutracker.org") if err == nil { resp.Body.Close() a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", resp.StatusCode)) } else { a.logEvent("validation: rutracker.org FAILED — " + err.Error()) } } // Disconnect stops the VPN and clears system proxy. func (a *App) Disconnect() error { 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 { server := a.findServer(serverTag) if server == nil { return fmt.Errorf("server not found: %s", serverTag) } addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort) if runtime.GOOS == "windows" { // Set SOCKS proxy via registry 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: " + 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 } // 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() return map[string]any{ "connected": a.engine.IsRunning(), "server": st.SelectedServer, "mode": st.SelectedMode, "lastSync": st.LastSync, } } // GetExitIP checks the actual exit IP through the proxy. func (a *App) GetExitIP() string { client := &http.Client{Timeout: 5 * time.Second} 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 { return map[string]any{ "default": config.BypassProcesses, "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 the path. func (a *App) DownloadUpdate() (string, error) { return a.updater.Download() } // 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))] } 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 }