summaryrefslogtreecommitdiff
path: root/cmd/client/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/client/app.go')
-rw-r--r--cmd/client/app.go427
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
+}