diff options
| author | SergeiEU <39683682+SergeiEU@users.noreply.github.com> | 2026-04-01 10:17:15 +0400 |
|---|---|---|
| committer | SergeiEU <39683682+SergeiEU@users.noreply.github.com> | 2026-04-01 10:17:15 +0400 |
| commit | 1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch) | |
| tree | d8c85273ede547e03a5727bf185f5d07e87b4a08 /cmd/client/app.go | |
| download | vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.gz vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.bz2 vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.zip | |
Diffstat (limited to 'cmd/client/app.go')
| -rw-r--r-- | cmd/client/app.go | 427 |
1 files changed, 427 insertions, 0 deletions
diff --git a/cmd/client/app.go b/cmd/client/app.go new file mode 100644 index 0000000..8257847 --- /dev/null +++ b/cmd/client/app.go @@ -0,0 +1,427 @@ +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 +} |
