diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/client/app.go | 427 | ||||
| -rw-r--r-- | cmd/client/build/appicon.png | bin | 0 -> 132625 bytes | |||
| -rw-r--r-- | cmd/client/build/windows/icon.ico | bin | 0 -> 21677 bytes | |||
| -rw-r--r-- | cmd/client/build/windows/info.json | 15 | ||||
| -rw-r--r-- | cmd/client/build/windows/wails.exe.manifest | 15 | ||||
| -rw-r--r-- | cmd/client/frontend/dist/index.html | 538 | ||||
| -rwxr-xr-x | cmd/client/frontend/wailsjs/go/main/App.d.ts | 44 | ||||
| -rwxr-xr-x | cmd/client/frontend/wailsjs/go/main/App.js | 83 | ||||
| -rwxr-xr-x | cmd/client/frontend/wailsjs/go/models.ts | 123 | ||||
| -rw-r--r-- | cmd/client/frontend/wailsjs/runtime/package.json | 24 | ||||
| -rw-r--r-- | cmd/client/frontend/wailsjs/runtime/runtime.d.ts | 330 | ||||
| -rw-r--r-- | cmd/client/frontend/wailsjs/runtime/runtime.js | 298 | ||||
| -rw-r--r-- | cmd/client/kill_other.go | 16 | ||||
| -rw-r--r-- | cmd/client/kill_windows.go | 56 | ||||
| -rw-r--r-- | cmd/client/main.go | 96 | ||||
| -rw-r--r-- | cmd/client/wails.json | 7 | ||||
| -rw-r--r-- | cmd/installer/main.go | 257 | ||||
| -rw-r--r-- | cmd/server/main.go | 36 |
18 files changed, 2365 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 +} diff --git a/cmd/client/build/appicon.png b/cmd/client/build/appicon.png Binary files differnew file mode 100644 index 0000000..63617fe --- /dev/null +++ b/cmd/client/build/appicon.png diff --git a/cmd/client/build/windows/icon.ico b/cmd/client/build/windows/icon.ico Binary files differnew file mode 100644 index 0000000..bfa0690 --- /dev/null +++ b/cmd/client/build/windows/icon.ico diff --git a/cmd/client/build/windows/info.json b/cmd/client/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/cmd/client/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +}
\ No newline at end of file diff --git a/cmd/client/build/windows/wails.exe.manifest b/cmd/client/build/windows/wails.exe.manifest new file mode 100644 index 0000000..d82139b --- /dev/null +++ b/cmd/client/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> + <assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/> + <dependency> + <dependentAssembly> + <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> + </dependentAssembly> + </dependency> + <asmv3:application> + <asmv3:windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> + </asmv3:windowsSettings> + </asmv3:application> +</assembly>
\ No newline at end of file diff --git a/cmd/client/frontend/dist/index.html b/cmd/client/frontend/dist/index.html new file mode 100644 index 0000000..6a77f85 --- /dev/null +++ b/cmd/client/frontend/dist/index.html @@ -0,0 +1,538 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>vpnem</title> + <style> +:root { + --bg: #191919; + --surface: #222; + --surface2: #2a2a2a; + --border: #333; + --border-focus: #555; + --text: #c8c4bd; + --text-dim: #706c64; + --text-faint: #4a4740; + --accent: #c9885a; + --accent-hover: #dda070; + --on: #7aad6a; + --on-dim: #3a5032; + --off: #b05050; + --off-dim: #4a2828; + --warn: #c9a84a; + --mono: 'Consolas', 'SF Mono', 'Menlo', monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { + font: 14px/1.5 'Segoe UI', 'Inter', system-ui, sans-serif; + background: var(--bg); color: var(--text); + user-select: none; overflow: hidden; +} +::-webkit-scrollbar { width:6px; } +::-webkit-scrollbar-track { background:transparent; } +::-webkit-scrollbar-thumb { background:#383838; border-radius:3px; } +::-webkit-scrollbar-thumb:hover { background:#484848; } + +/* Layout */ +.app { display: flex; flex-direction: column; height: 100vh; } + +/* Header */ +.header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--border); + background: var(--surface); -webkit-app-region: drag; +} +.header .brand { font: 700 16px var(--mono); color: var(--accent); letter-spacing: 1.5px; text-transform: uppercase; } +.header .conn-info { flex: 1; text-align: center; font: 12px var(--mono); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 10px; transition: color .3s; } +.header .conn-info.on { color: var(--text-dim); } +.header .status { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.header .status .indicator { + width: 8px; height: 8px; border-radius: 50%; background: var(--off); + transition: background .3s; +} +.header .status .indicator.on { background: var(--on); } +.header .status .label { color: var(--text-dim); transition: color .3s; } +.header .status .label.on { color: var(--on); } + +/* Toast notifications */ +.toast { + position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); + padding: 8px 18px; font: 13px var(--mono); border-radius: 3px; + background: var(--surface2); border: 1px solid var(--border); color: var(--text); + opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 100; +} +.toast.show { opacity: 1; } +.toast.ok { border-color: var(--on); color: var(--on); } +.toast.err { border-color: var(--off); color: var(--off); } + +/* Nav */ +nav { + display: flex; border-bottom: 1px solid var(--border); background: var(--surface); +} +nav button { + flex: 1; padding: 10px 0; font: 600 12px/1 inherit; text-transform: uppercase; + letter-spacing: 1px; color: var(--text-dim); background: none; + border: none; border-bottom: 2px solid transparent; cursor: pointer; + transition: color .15s, border-color .15s; +} +nav button:hover { color: var(--text); } +nav button.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* Content area */ +.content { flex: 1; overflow-y: auto; padding: 16px 18px; } +.panel { display: none; } +.panel.active { display: block; } + +/* Form elements */ +label.field { display: block; margin-bottom: 14px; } +label.field .lbl { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin-bottom: 6px; } +select, input[type=text] { + width: 100%; padding: 9px 12px; font: 14px inherit; color: var(--text); + background: var(--surface); border: 1px solid var(--border); border-radius: 3px; + outline: none; cursor: pointer; transition: border-color .15s; +} +select:focus, input[type=text]:focus { border-color: var(--border-focus); } +input[type=text] { cursor: text; } + +/* Primary action */ +.btn-primary { + width: 100%; padding: 12px; margin-top: 6px; font: 700 14px inherit; + text-transform: uppercase; letter-spacing: 1.5px; border: 1px solid var(--on); + border-radius: 3px; background: var(--on-dim); color: var(--on); cursor: pointer; + transition: all .15s; +} +.btn-primary:active { transform: scale(.98); } +.btn-primary:hover { background: var(--on); color: var(--bg); } +.btn-primary.disconnect { border-color: var(--off); background: var(--off-dim); color: var(--off); } +.btn-primary.disconnect:hover { background: var(--off); color: var(--bg); } +.btn-primary:disabled { opacity: .5; cursor: default; } + +/* Secondary buttons */ +.btn { display: inline-block; padding: 5px 12px; font: 11px inherit; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; cursor: pointer; } +.btn:hover { color: var(--text); border-color: var(--border-focus); } + +/* Exit IP line */ +.meta { margin-top: 10px; font: 13px var(--mono); color: var(--text-dim); text-align: center; } +.meta em { font-style: normal; color: var(--accent); } + +/* Checkbox row */ +.check-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; color: var(--text-dim); } +.check-row input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; } + +/* Toggle list rows */ +.row { + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; border-bottom: 1px solid var(--border); + font-size: 12px; +} +.row:last-child { border-bottom: none; } +.row .name { flex: 1; font-family: var(--mono); font-size: 13px; color: var(--text); } +.row .desc { flex: 2; color: var(--text-dim); font-size: 12px; } +.row .badge { font: 600 9px var(--mono); text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 5px; border-radius: 2px; } +.row .badge.on { background: var(--on-dim); color: var(--on); } +.row .badge.always { background: #333; color: var(--text-dim); } + +/* Toggle switch */ +.sw { position: relative; width: 32px; height: 18px; flex-shrink: 0; } +.sw input { display: none; } +.sw span { + position: absolute; inset: 0; background: #3a3a3a; border-radius: 9px; + cursor: pointer; transition: .2s; +} +.sw span::after { + content: ''; position: absolute; width: 12px; height: 12px; + left: 3px; top: 3px; background: #666; border-radius: 50%; transition: .2s; +} +.sw input:checked + span { background: var(--on-dim); } +.sw input:checked + span::after { transform: translateX(14px); background: var(--on); } + +/* Process items */ +.proc { display: flex; align-items: center; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; } +.proc:hover { background: var(--surface); } +.proc .pname { flex: 1; } +.proc.builtin .pname { color: var(--text-dim); } +.proc .x { background: none; border: none; color: var(--off); cursor: pointer; font-size: 13px; padding: 0 4px; } +.proc .x:hover { color: #e06060; } +.add-row { display: flex; gap: 4px; margin-top: 6px; } +.add-row input { flex: 1; font-size: 12px; padding: 5px 8px; } +.add-row .btn { padding: 5px 14px; } + +/* Latency rows */ +.lat { display: flex; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; } +.lat:hover { background: var(--surface); } +.lat .tag { flex: 1; } +.lat .ms { min-width: 55px; text-align: right; } +.lat .ms.g { color: var(--on); } +.lat .ms.m { color: var(--warn); } +.lat .ms.b { color: var(--off); } +.lat .ms.d { color: var(--text-faint); } + +/* Log viewer */ +.logview { + background: #111; border: 1px solid var(--border); border-radius: 3px; + padding: 8px 10px; max-height: 220px; overflow-y: auto; + font: 11px/1.7 var(--mono); color: var(--text-dim); white-space: pre-wrap; word-break: break-all; +} + +/* Section headers inside panels */ +.sh { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-faint); margin: 14px 0 8px; } +.sh:first-child { margin-top: 0; } + +/* Footer */ +.footer { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 16px; border-top: 1px solid var(--border); + background: var(--surface); font-size: 12px; color: var(--text-faint); +} +.footer .update { color: var(--accent); cursor: pointer; } + </style> +</head> +<body> +<div class="app"> + <div class="header"> + <span class="brand">vpnem</span> + <span class="conn-info" id="connInfo"></span> + <div class="status"> + <div class="indicator" id="ind"></div> + <span class="label" id="statusLabel">offline</span> + </div> + </div> + <div class="toast" id="toast"></div> + + <nav> + <button class="active" onclick="tab('vpn')">vpn</button> + <button onclick="tab('rules')">rules</button> + <button onclick="tab('bypass')">bypass</button> + <button onclick="tab('diag')">diag</button> + </nav> + + <div class="content"> + <!-- VPN panel --> + <div id="p-vpn" class="panel active"> + <label class="field"> + <span class="lbl">Server</span> + <select id="selServer"></select> + </label> + <label class="field"> + <span class="lbl">Routing mode</span> + <select id="selMode"></select> + </label> + <button class="btn-primary" id="btnConn" onclick="doConnect()">Connect</button> + <div class="meta" id="exitIp"></div> + <div class="check-row"> + <input type="checkbox" id="chkAuto" onchange="window.go.main.App.SetAutoConnect(this.checked)"> + <span>Auto-connect on launch</span> + </div> + </div> + + <!-- Rules panel --> + <div id="p-rules" class="panel"> + <div class="sh">Rule sets</div> + <div id="rsList"></div> + </div> + + <!-- Bypass panel --> + <div id="p-bypass" class="panel"> + <div class="sh">Built-in exclusions</div> + <div id="procDefault"></div> + <div class="sh">Custom exclusions</div> + <div id="procCustom"></div> + <div class="add-row"> + <input type="text" id="inpProc" placeholder="process.exe" onkeydown="if(event.key==='Enter')addProc()"> + <button class="btn" onclick="addProc()">Add</button> + </div> + </div> + + <!-- Diag panel --> + <div id="p-diag" class="panel"> + <div class="sh">Latency <button class="btn" onclick="measureLat()" style="margin-left:6px">Measure</button></div> + <div id="latList"></div> + <div class="sh" style="margin-top:14px">Log <button class="btn" onclick="loadLog()" style="margin-left:6px">Refresh</button></div> + <div class="logview" id="logBox">No logs</div> + </div> + </div> + + <div class="footer"> + <span id="syncInfo">--</span> + <span> + <span id="updInfo" style="display:none" class="update"></span> + <button id="updBtn" style="display:none" class="btn" onclick="doUpdate()">update</button> + <button class="btn" onclick="doSync()">sync</button> + </span> + </div> +</div> + +<script> +let on = false; +let toastTimer = null; +const $ = s => document.getElementById(s); +const A = () => window.go.main.App; + +function toast(msg, type) { + const t = $('toast'); + t.textContent = msg; + t.className = 'toast show' + (type ? ' ' + type : ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.className = 'toast', 3000); +} + +function tab(name) { + document.querySelectorAll('nav button').forEach((b,i) => b.classList.toggle('active', b.textContent.trim() === name)); + document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); + $('p-' + name).classList.add('active'); + if (name === 'rules') loadRules(); + if (name === 'bypass') loadProcs(); + if (name === 'diag') loadLog(); +} + +async function init() { + for (let i = 0; i < 50; i++) { + if (window.go && window.go.main && window.go.main.App) break; + await new Promise(r => setTimeout(r, 100)); + } + if (!A()) { $('syncInfo').textContent = 'runtime error'; return; } + + try { + // Load modes (always available, static) + const modes = await A().GetModes(); + const sel = $('selMode'); + (modes||[]).forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); }); + + // Wait for sync to complete (may still be running in background) + let servers = await A().GetServers(); + if (!servers || !servers.length) { + $('syncInfo').textContent = 'syncing...'; + // Wait up to 10s for background sync + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 500)); + servers = await A().GetServers(); + if (servers && servers.length) break; + } + if (!servers || !servers.length) { + // Force sync + await A().Sync(); + servers = await A().GetServers(); + } + } + + const status = await A().GetStatus(); + + fillServers(servers || []); + + // Set saved or random NL server + if (status.server) { + $('selServer').value = status.server; + } else { + try { + const rnd = await A().RandomNLServer(); + if (rnd) $('selServer').value = rnd; + } catch(e) {} + } + + // Set saved mode or default to last (Combo) + if (status.mode) { + $('selMode').value = status.mode; + } else if (modes && modes.length) { + $('selMode').value = modes[modes.length - 1]; + } + if (status.connected) { setOn(true); setTimeout(getIP, 2000); } + $('chkAuto').checked = !!status.autoConnect; + $('syncInfo').textContent = (servers||[]).length + ' servers'; + } catch(e) { $('syncInfo').textContent = String(e); } + + setTimeout(checkUpd, 5000); + + // Listen for backend sync events and refresh UI + if (window.runtime && window.runtime.EventsOn) { + window.runtime.EventsOn('connected', (serverTag) => { + $('selServer').value = serverTag; + setOn(true, serverTag); + }); + window.runtime.EventsOn('synced', async () => { + try { + const servers = await A().GetServers(); + const status = await A().GetStatus(); + $('selServer').textContent = ''; + fillServers(servers || []); + if (status.server) { + $('selServer').value = status.server; + } else { + const rnd = await A().RandomNLServer(); + if (rnd) $('selServer').value = rnd; + } + if (status.mode) $('selMode').value = status.mode; + $('syncInfo').textContent = (servers||[]).length + ' servers'; + } catch(e) {} + }); + } +} + +function fillServers(list) { + const sel = $('selServer'); + const groups = {}; + list.forEach(s => { (groups[s.region] = groups[s.region]||[]).push(s); }); + for (const [r, ss] of Object.entries(groups)) { + const g = document.createElement('optgroup'); g.label = r; + ss.forEach(s => { const o = document.createElement('option'); o.value = s.tag; o.textContent = s.tag + ' \u00b7 ' + s.type; g.appendChild(o); }); + sel.appendChild(g); + } +} + +async function doConnect() { + $('btnConn').disabled = true; + $('btnConn').textContent = on ? 'Disconnecting...' : 'Connecting...'; + try { + if (on) { + await A().Disconnect(); + setOn(false); + } else { + const srv = $('selServer').value; + const mode = $('selMode').value; + if (!srv) { toast('Select a server', 'err'); $('btnConn').disabled = false; $('btnConn').textContent = 'Connect'; return; } + await A().Connect(srv, mode); + setOn(true); + setTimeout(getIP, 2500); + } + } catch(e) { + setOn(false); + toast(String(e).substring(0, 80), 'err'); + } + $('btnConn').disabled = false; +} + +function setOn(state, serverTag) { + const wasOn = on; + on = state; + $('ind').className = 'indicator' + (state ? ' on' : ''); + $('statusLabel').textContent = state ? 'connected' : 'offline'; + $('statusLabel').className = 'label' + (state ? ' on' : ''); + const b = $('btnConn'); + b.textContent = state ? 'Disconnect' : 'Connect'; + b.className = 'btn-primary' + (state ? ' disconnect' : ''); + if (state) { + const srv = serverTag || $('selServer').value; + const mode = $('selMode').value; + $('connInfo').textContent = srv + ' \u00b7 ' + mode; + $('connInfo').className = 'conn-info on'; + if (!wasOn) toast('Connected: ' + srv, 'ok'); + } else { + $('connInfo').textContent = ''; + $('connInfo').className = 'conn-info'; + $('exitIp').textContent = ''; + if (wasOn) toast('Disconnected', 'err'); + } +} + +async function getIP() { + try { + const ip = await A().GetExitIP(); + if (ip) { $('exitIp').textContent = ''; const t = document.createTextNode('exit '); $('exitIp').appendChild(t); const e = document.createElement('em'); e.textContent = ip.trim(); $('exitIp').appendChild(e); } + } catch(e) {} +} + +async function doSync() { + $('syncInfo').textContent = 'syncing\u2026'; + try { await A().Sync(); const s = await A().GetServers(); $('selServer').textContent = ''; fillServers(s||[]); $('syncInfo').textContent = (s||[]).length + ' servers'; } + catch(e) { $('syncInfo').textContent = 'error'; } +} + +// Rules +async function loadRules() { + const c = $('rsList'); c.textContent = ''; + try { + const rs = await A().GetRuleSets(); + (rs||[]).forEach(r => { + const row = document.createElement('div'); row.className = 'row'; + const nm = document.createElement('span'); nm.className = 'name'; nm.textContent = r.tag; row.appendChild(nm); + const ds = document.createElement('span'); ds.className = 'desc'; ds.textContent = r.description; row.appendChild(ds); + if (r.optional) { + const sw = document.createElement('label'); sw.className = 'sw'; + const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = r.enabled; + inp.onchange = () => A().SetRuleSetEnabled(r.tag, inp.checked); + const sp = document.createElement('span'); + sw.appendChild(inp); sw.appendChild(sp); row.appendChild(sw); + } else { + const b = document.createElement('span'); b.className = 'badge always'; b.textContent = 'active'; row.appendChild(b); + } + c.appendChild(row); + }); + } catch(e) {} +} + +// Bypass processes +async function loadProcs() { + try { + const d = await A().GetBypassProcesses(); + const df = $('procDefault'); df.textContent = ''; + (d.default||[]).forEach(p => { const r = document.createElement('div'); r.className = 'proc builtin'; const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n); df.appendChild(r); }); + const cf = $('procCustom'); cf.textContent = ''; + (d.custom||[]).forEach(p => { + const r = document.createElement('div'); r.className = 'proc'; + const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n); + const x = document.createElement('button'); x.className = 'x'; x.textContent = '\u00d7'; x.onclick = async()=>{ await A().RemoveBypassProcess(p); loadProcs(); }; r.appendChild(x); + cf.appendChild(r); + }); + } catch(e) {} +} + +async function addProc() { + const v = $('inpProc').value.trim(); if (!v) return; + await A().AddBypassProcess(v); $('inpProc').value = ''; loadProcs(); +} + +// Latency +async function measureLat() { + const c = $('latList'); c.textContent = 'measuring\u2026'; + try { + const res = await A().MeasureLatency(); c.textContent = ''; + res.forEach(r => { + const row = document.createElement('div'); row.className = 'lat'; + const t = document.createElement('span'); t.className = 'tag'; t.textContent = r.tag; row.appendChild(t); + const m = document.createElement('span'); m.className = 'ms'; + if (r.latency_ms < 0) { m.textContent = '\u2014'; m.classList.add('d'); } + else if (r.latency_ms < 80) { m.textContent = r.latency_ms + 'ms'; m.classList.add('g'); } + else if (r.latency_ms < 200) { m.textContent = r.latency_ms + 'ms'; m.classList.add('m'); } + else { m.textContent = r.latency_ms + 'ms'; m.classList.add('b'); } + row.appendChild(m); c.appendChild(row); + }); + } catch(e) { c.textContent = String(e); } +} + +async function loadLog() { + try { const l = await A().GetLogs(); $('logBox').textContent = (l||[]).join('\n') || 'empty'; $('logBox').scrollTop = $('logBox').scrollHeight; } catch(e) {} +} + +async function checkUpd() { + try { + const i = await A().CheckUpdate(); + if (i && i.available) { + $('updInfo').style.display = 'inline'; + $('updInfo').textContent = 'v' + i.version + ' available'; + $('updBtn').style.display = 'inline-block'; + } + } catch(e) {} +} + +async function doUpdate() { + $('updBtn').disabled = true; + $('updBtn').textContent = 'updating...'; + $('updInfo').textContent = 'downloading, app will restart'; + try { + await A().DownloadUpdate(); + // If we get here, restart failed — shouldn't normally happen + $('updInfo').textContent = 'restart manually'; + } catch(e) { + $('updBtn').textContent = 'failed'; + $('updInfo').textContent = String(e).substring(0, 40); + setTimeout(() => { $('updBtn').textContent = 'retry'; $('updBtn').disabled = false; }, 3000); + } +} + +setInterval(async()=>{ try { const s = await A().GetStatus(); if (s.connected !== on) { setOn(s.connected); if (s.connected) getIP(); } } catch(e) {} }, 5000); +init(); +</script> +</body> +</html> diff --git a/cmd/client/frontend/wailsjs/go/main/App.d.ts b/cmd/client/frontend/wailsjs/go/main/App.d.ts new file mode 100755 index 0000000..72187a0 --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,44 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {sync} from '../models'; +import {models} from '../models'; + +export function AddBypassProcess(arg1:string):Promise<void>; + +export function CheckUpdate():Promise<sync.UpdateInfo>; + +export function Connect(arg1:string,arg2:string):Promise<void>; + +export function Disconnect():Promise<void>; + +export function DownloadUpdate():Promise<string>; + +export function GetBypassProcesses():Promise<Record<string, any>>; + +export function GetExitIP():Promise<string>; + +export function GetGeneratedConfig():Promise<string>; + +export function GetLogs():Promise<Array<string>>; + +export function GetModes():Promise<Array<string>>; + +export function GetRuleSets():Promise<Array<Record<string, any>>>; + +export function GetServers():Promise<Array<models.Server>>; + +export function GetStatus():Promise<Record<string, any>>; + +export function MeasureLatency():Promise<Array<sync.LatencyResult>>; + +export function RandomNLServer():Promise<string>; + +export function RemoveBypassProcess(arg1:string):Promise<void>; + +export function SetAutoConnect(arg1:boolean):Promise<void>; + +export function SetRuleSetEnabled(arg1:string,arg2:boolean):Promise<void>; + +export function SetSystemProxy(arg1:string):Promise<void>; + +export function Sync():Promise<void>; diff --git a/cmd/client/frontend/wailsjs/go/main/App.js b/cmd/client/frontend/wailsjs/go/main/App.js new file mode 100755 index 0000000..917c8f0 --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/main/App.js @@ -0,0 +1,83 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddBypassProcess(arg1) { + return window['go']['main']['App']['AddBypassProcess'](arg1); +} + +export function CheckUpdate() { + return window['go']['main']['App']['CheckUpdate'](); +} + +export function Connect(arg1, arg2) { + return window['go']['main']['App']['Connect'](arg1, arg2); +} + +export function Disconnect() { + return window['go']['main']['App']['Disconnect'](); +} + +export function DownloadUpdate() { + return window['go']['main']['App']['DownloadUpdate'](); +} + +export function GetBypassProcesses() { + return window['go']['main']['App']['GetBypassProcesses'](); +} + +export function GetExitIP() { + return window['go']['main']['App']['GetExitIP'](); +} + +export function GetGeneratedConfig() { + return window['go']['main']['App']['GetGeneratedConfig'](); +} + +export function GetLogs() { + return window['go']['main']['App']['GetLogs'](); +} + +export function GetModes() { + return window['go']['main']['App']['GetModes'](); +} + +export function GetRuleSets() { + return window['go']['main']['App']['GetRuleSets'](); +} + +export function GetServers() { + return window['go']['main']['App']['GetServers'](); +} + +export function GetStatus() { + return window['go']['main']['App']['GetStatus'](); +} + +export function MeasureLatency() { + return window['go']['main']['App']['MeasureLatency'](); +} + +export function RandomNLServer() { + return window['go']['main']['App']['RandomNLServer'](); +} + +export function RemoveBypassProcess(arg1) { + return window['go']['main']['App']['RemoveBypassProcess'](arg1); +} + +export function SetAutoConnect(arg1) { + return window['go']['main']['App']['SetAutoConnect'](arg1); +} + +export function SetRuleSetEnabled(arg1, arg2) { + return window['go']['main']['App']['SetRuleSetEnabled'](arg1, arg2); +} + +export function SetSystemProxy(arg1) { + return window['go']['main']['App']['SetSystemProxy'](arg1); +} + +export function Sync() { + return window['go']['main']['App']['Sync'](); +} diff --git a/cmd/client/frontend/wailsjs/go/models.ts b/cmd/client/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..241727b --- /dev/null +++ b/cmd/client/frontend/wailsjs/go/models.ts @@ -0,0 +1,123 @@ +export namespace models { + + export class Transport { + type?: string; + path?: string; + + static createFrom(source: any = {}) { + return new Transport(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.type = source["type"]; + this.path = source["path"]; + } + } + export class TLS { + enabled: boolean; + server_name?: string; + + static createFrom(source: any = {}) { + return new TLS(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.server_name = source["server_name"]; + } + } + export class Server { + tag: string; + region: string; + type: string; + server: string; + server_port: number; + udp_over_tcp?: boolean; + uuid?: string; + method?: string; + password?: string; + tls?: TLS; + transport?: Transport; + + static createFrom(source: any = {}) { + return new Server(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tag = source["tag"]; + this.region = source["region"]; + this.type = source["type"]; + this.server = source["server"]; + this.server_port = source["server_port"]; + this.udp_over_tcp = source["udp_over_tcp"]; + this.uuid = source["uuid"]; + this.method = source["method"]; + this.password = source["password"]; + this.tls = this.convertValues(source["tls"], TLS); + this.transport = this.convertValues(source["transport"], Transport); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + +} + +export namespace sync { + + export class LatencyResult { + tag: string; + region: string; + latency_ms: number; + + static createFrom(source: any = {}) { + return new LatencyResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tag = source["tag"]; + this.region = source["region"]; + this.latency_ms = source["latency_ms"]; + } + } + export class UpdateInfo { + available: boolean; + version: string; + changelog: string; + current_version: string; + + static createFrom(source: any = {}) { + return new UpdateInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.available = source["available"]; + this.version = source["version"]; + this.changelog = source["changelog"]; + this.current_version = source["current_version"]; + } + } + +} + diff --git a/cmd/client/frontend/wailsjs/runtime/package.json b/cmd/client/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony <lea.anthony@gmail.com>", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.d.ts b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise<boolean>; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise<Size>; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise<Position>; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise<boolean>; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise<boolean>; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise<boolean>; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise<Screen[]>; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise<EnvironmentInfo>; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise<string>; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise<boolean>; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise<void>; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise<void>; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise<boolean>; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise<boolean>; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise<boolean>; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise<void>; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise<void>; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise<void>; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise<void>; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise<void>; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise<void>; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise<void>; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise<void>;
\ No newline at end of file diff --git a/cmd/client/frontend/wailsjs/runtime/runtime.js b/cmd/client/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/cmd/client/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +}
\ No newline at end of file diff --git a/cmd/client/kill_other.go b/cmd/client/kill_other.go new file mode 100644 index 0000000..75ac0a5 --- /dev/null +++ b/cmd/client/kill_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import ( + "log" + "os/exec" + "time" +) + +func killPrevious() { + log.Println("killing previous instances") + exec.Command("pkill", "-f", "sing-box").Run() + // Don't pkill vpnem — we ARE vpnem + time.Sleep(500 * time.Millisecond) +} diff --git a/cmd/client/kill_windows.go b/cmd/client/kill_windows.go new file mode 100644 index 0000000..ba8c75a --- /dev/null +++ b/cmd/client/kill_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package main + +import ( + "log" + "os" + "os/exec" + "strconv" + "time" +) + +func killPrevious() { + myPID := os.Getpid() + log.Printf("killing previous instances (my PID: %d)", myPID) + + // Kill other vpnem.exe processes (not ourselves) + // Use WMIC to find PIDs, then taskkill each except ours + out, _ := exec.Command("wmic", "process", "where", + "name='vpnem.exe'", "get", "processid", "/format:list").Output() + for _, line := range splitLines(string(out)) { + if len(line) > 10 && line[:10] == "ProcessId=" { + pidStr := line[10:] + pid, err := strconv.Atoi(pidStr) + if err == nil && pid != myPID { + log.Printf("killing old vpnem.exe PID %d", pid) + exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run() + } + } + } + + // Kill any orphaned sing-box.exe + exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run() + time.Sleep(500 * time.Millisecond) +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' || s[i] == '\r' { + line := s[start:i] + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + if line != "" { + lines = append(lines, line) + } + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..0b2fd6f --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "embed" + "flag" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed frontend/dist +var assets embed.FS + +func main() { + apiURL := flag.String("api", "https://vpn.em-sysadmin.xyz", "API server URL") + dataDir := flag.String("data", defaultDataDir(), "data directory") + flag.Parse() + + // Ensure data dir exists + os.MkdirAll(*dataDir, 0o755) + + // Setup file logging so we always have diagnostics + logPath := filepath.Join(*dataDir, "vpnem.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + log.SetOutput(logFile) + defer logFile.Close() + } + + log.Printf("vpnem starting, data=%s, api=%s, os=%s", *dataDir, *apiURL, runtime.GOOS) + + // Kill previous instances of vpnem and sing-box + killPrevious() + + // Check wintun.dll on Windows + if runtime.GOOS == "windows" { + exe, _ := os.Executable() + exeDir := filepath.Dir(exe) + wintunPaths := []string{ + filepath.Join(exeDir, "wintun.dll"), + filepath.Join(*dataDir, "wintun.dll"), + "wintun.dll", + } + found := false + for _, p := range wintunPaths { + if _, err := os.Stat(p); err == nil { + log.Printf("wintun.dll found: %s", p) + found = true + break + } + } + if !found { + log.Printf("WARNING: wintun.dll not found! TUN will fail. Searched: %v", wintunPaths) + } + } + + app := NewApp(*dataDir, *apiURL) + + log.Println("starting Wails UI") + if err := wails.Run(&options.App{ + Title: "vpnem", + Width: 480, + Height: 600, + MinWidth: 400, + MinHeight: 500, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + }); err != nil { + log.Printf("FATAL wails error: %v", err) + // On Windows, also write to a visible error file + if runtime.GOOS == "windows" { + errPath := filepath.Join(*dataDir, "ERROR.txt") + os.WriteFile(errPath, []byte("vpnem failed to start:\n"+err.Error()+"\n\nCheck vpnem.log for details.\n"), 0o644) + } + os.Exit(1) + } +} + +func defaultDataDir() string { + if runtime.GOOS == "windows" { + return `C:\ProgramData\vpnem` + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "vpnem") +} diff --git a/cmd/client/wails.json b/cmd/client/wails.json new file mode 100644 index 0000000..0f64984 --- /dev/null +++ b/cmd/client/wails.json @@ -0,0 +1,7 @@ +{ + "name": "vpnem", + "outputfilename": "vpnem", + "author": { + "name": "sergei" + } +} diff --git a/cmd/installer/main.go b/cmd/installer/main.go new file mode 100644 index 0000000..ac21dc9 --- /dev/null +++ b/cmd/installer/main.go @@ -0,0 +1,257 @@ +// vpnem-installer: Windows installer (GUI, no console window). +// Installs to Program Files, creates Task Scheduler task for UAC-free launch. +// Requires admin (one-time). Supports silent mode: vpnem-installer.exe /S +// Cross-compiles from Linux with -ldflags "-H windowsgui" +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + installDir = `C:\Program Files\vpnem` + dataDir = `C:\ProgramData\vpnem` + taskName = "vpnem" + + baseURL = "https://vpn.em-sysadmin.xyz/releases" + vpnemURL = baseURL + "/vpnem-windows-amd64.exe" + singboxURL = baseURL + "/sing-box.exe" + wintunURL = baseURL + "/wintun.dll" +) + +var ( + silent bool + noShortcut bool + launch bool + logFile *os.File +) + +func main() { + // Parse flags + for _, arg := range os.Args[1:] { + a := strings.ToLower(strings.TrimLeft(arg, "/-")) + switch a { + case "s", "silent": + silent = true + case "noshortcut": + noShortcut = true + case "launch": + launch = true + } + } + + os.MkdirAll(installDir, 0o755) + os.MkdirAll(dataDir, 0o755) + + // Log to data dir + lf, err := os.Create(filepath.Join(dataDir, "install.log")) + if err == nil { + logFile = lf + defer lf.Close() + log.SetOutput(lf) + } + + step("vpnem installer started") + step("install dir: " + installDir) + step("data dir: " + dataDir) + + // Kill running instances + step("stopping running instances") + exec.Command("taskkill", "/F", "/IM", "vpnem.exe").Run() + time.Sleep(time.Second) + + // Clean stale configs + step("cleaning old state") + for _, f := range []string{"state.json", "config.json", "cache.db"} { + os.Remove(filepath.Join(dataDir, f)) + os.Remove(filepath.Join(installDir, f)) + // Also clean old C:\ProxySwitcher location + os.Remove(filepath.Join(`C:\ProxySwitcher`, f)) + } + + // Download vpnem.exe + step("downloading vpnem") + if err := download(vpnemURL, filepath.Join(installDir, "vpnem.exe")); err != nil { + fatal("download vpnem: %v", err) + } + + // Download sing-box 1.11 (external subprocess, proven to work) + downloadIfMissing("sing-box.exe", singboxURL) + + // Download wintun.dll + downloadIfMissing("wintun.dll", wintunURL) + + // Create Task Scheduler task — runs vpnem as admin WITHOUT UAC prompt. + // This is the key: task created by admin runs with highest privileges silently. + step("creating scheduled task (UAC-free launch)") + createTask() + + // Desktop shortcut — launches via schtasks (no UAC popup) + if !noShortcut { + step("creating desktop shortcut") + createShortcut() + } + + step("installation complete") + + if launch { + step("launching vpnem") + exec.Command("schtasks", "/Run", "/TN", taskName).Run() + } + + if !silent { + showDoneMessage() + } +} + +// createTask sets up a scheduled task that: +// 1. Runs vpnem.exe with highest privileges (no UAC) +// 2. Starts at logon with 15s delay (autostart) +// The same task is used for both manual launch and autostart. +func createTask() { + exec.Command("schtasks", "/Delete", "/TN", taskName, "/F").Run() + + // Create XML task definition for full control + exePath := filepath.Join(installDir, "vpnem.exe") + xml := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-16"?> +<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <RegistrationInfo> + <Description>vpnem VPN client</Description> + </RegistrationInfo> + <Triggers> + <LogonTrigger> + <Enabled>true</Enabled> + <Delay>PT15S</Delay> + </LogonTrigger> + </Triggers> + <Principals> + <Principal> + <LogonType>InteractiveToken</LogonType> + <RunLevel>HighestAvailable</RunLevel> + </Principal> + </Principals> + <Settings> + <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> + <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> + <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> + <ExecutionTimeLimit>PT0S</ExecutionTimeLimit> + <AllowStartOnDemand>true</AllowStartOnDemand> + <AllowHardTerminate>true</AllowHardTerminate> + </Settings> + <Actions> + <Exec> + <Command>%s</Command> + <Arguments>--data "%s"</Arguments> + <WorkingDirectory>%s</WorkingDirectory> + </Exec> + </Actions> +</Task>`, exePath, dataDir, installDir) + + xmlPath := filepath.Join(dataDir, "task.xml") + os.WriteFile(xmlPath, []byte(xml), 0o644) + + cmd := exec.Command("schtasks", "/Create", "/TN", taskName, "/XML", xmlPath, "/F") + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("task create failed: %v\n%s", err, string(out)) + } else { + log.Println("task created ok") + } + os.Remove(xmlPath) +} + +// createShortcut makes a desktop shortcut that runs the scheduled task (no UAC) +// IconLocation points to vpnem.exe so the shortcut has the app icon +func createShortcut() { + exePath := filepath.Join(installDir, "vpnem.exe") + ps := ` +$ws = New-Object -ComObject WScript.Shell +$s = $ws.CreateShortcut("$env:USERPROFILE\Desktop\vpnem.lnk") +$s.TargetPath = "schtasks.exe" +$s.Arguments = "/Run /TN vpnem" +$s.WorkingDirectory = "` + installDir + `" +$s.IconLocation = "` + exePath + `,0" +$s.Description = "vpnem VPN client" +$s.Save() +` + cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) + if err := cmd.Run(); err != nil { + log.Printf("shortcut failed: %v (non-critical)", err) + } +} + +func step(msg string) { + log.Println(msg) +} + +func fatal(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + log.Printf("FATAL: %s", msg) + if silent { + os.Exit(1) + } + showError(msg) + os.Exit(1) +} + +func download(url, dest string) error { + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + tmp := dest + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + + written, err := io.Copy(f, resp.Body) + f.Close() + if err != nil { + os.Remove(tmp) + return err + } + + log.Printf(" %s (%.1f MB)", filepath.Base(dest), float64(written)/1024/1024) + return os.Rename(tmp, dest) +} + +func downloadIfMissing(filename, url string) { + path := filepath.Join(installDir, filename) + if _, err := os.Stat(path); os.IsNotExist(err) { + step("downloading " + filename) + if err := download(url, path); err != nil { + fatal("download %s: %v", filename, err) + } + } else { + step(filename + " already present, skipping") + } +} + +func showDoneMessage() { + msg := fmt.Sprintf("vpnem installed to %s\\n\\nDesktop shortcut created.\\nAutostart at logon enabled.\\n\\nNo admin prompts needed to launch.", installDir) + exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show("%s", "vpnem installer", "OK", "Information")`, msg), + ).Run() +} + +func showError(msg string) { + exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show("Installation failed:\n%s", "vpnem installer", "OK", "Error")`, msg), + ).Run() +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4382c70 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "flag" + "log" + "net/http" + + "vpnem/internal/api" + "vpnem/internal/rules" +) + +func main() { + addr := flag.String("addr", ":8090", "listen address") + dataDir := flag.String("data", "./data", "path to data directory") + flag.Parse() + + store := rules.NewStore(*dataDir) + + // Verify data loads on startup + if _, err := store.LoadServers(); err != nil { + log.Fatalf("failed to load servers.json: %v", err) + } + if _, err := store.LoadRuleSets(); err != nil { + log.Fatalf("failed to load rulesets.json: %v", err) + } + if _, err := store.LoadVersion(); err != nil { + log.Fatalf("failed to load version.json: %v", err) + } + + router := api.NewRouter(store) + + log.Printf("vpnem server listening on %s", *addr) + if err := http.ListenAndServe(*addr, router); err != nil { + log.Fatal(err) + } +} |
