summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
committerSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
commit1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch)
treed8c85273ede547e03a5727bf185f5d07e87b4a08
downloadvpnem-main.tar.gz
vpnem-main.tar.bz2
vpnem-main.zip
Initial importHEADmain
-rw-r--r--.gitignore11
-rw-r--r--Dockerfile17
-rw-r--r--Makefile60
-rw-r--r--cmd/client/app.go427
-rw-r--r--cmd/client/build/appicon.pngbin0 -> 132625 bytes
-rw-r--r--cmd/client/build/windows/icon.icobin0 -> 21677 bytes
-rw-r--r--cmd/client/build/windows/info.json15
-rw-r--r--cmd/client/build/windows/wails.exe.manifest15
-rw-r--r--cmd/client/frontend/dist/index.html538
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.d.ts44
-rwxr-xr-xcmd/client/frontend/wailsjs/go/main/App.js83
-rwxr-xr-xcmd/client/frontend/wailsjs/go/models.ts123
-rw-r--r--cmd/client/frontend/wailsjs/runtime/package.json24
-rw-r--r--cmd/client/frontend/wailsjs/runtime/runtime.d.ts330
-rw-r--r--cmd/client/frontend/wailsjs/runtime/runtime.js298
-rw-r--r--cmd/client/kill_other.go16
-rw-r--r--cmd/client/kill_windows.go56
-rw-r--r--cmd/client/main.go96
-rw-r--r--cmd/client/wails.json7
-rw-r--r--cmd/installer/main.go257
-rw-r--r--cmd/server/main.go36
-rw-r--r--data/rulesets.json52
-rw-r--r--data/servers.json19
-rw-r--r--data/version.json1
-rw-r--r--docker-compose.yml18
-rw-r--r--go.mod114
-rw-r--r--go.sum262
-rw-r--r--internal/api/handlers.go54
-rw-r--r--internal/api/handlers_test.go129
-rw-r--r--internal/api/router.go30
-rw-r--r--internal/config/builder.go182
-rw-r--r--internal/config/builder_test.go232
-rw-r--r--internal/config/bypass.go139
-rw-r--r--internal/config/modes.go176
-rw-r--r--internal/engine/engine.go134
-rw-r--r--internal/engine/logger.go62
-rw-r--r--internal/engine/watchdog.go142
-rw-r--r--internal/models/ruleset.go22
-rw-r--r--internal/models/server.go29
-rw-r--r--internal/rules/loader.go61
-rw-r--r--internal/state/state.go137
-rw-r--r--internal/sync/fetcher.go82
-rw-r--r--internal/sync/health.go33
-rw-r--r--internal/sync/latency.go62
-rw-r--r--internal/sync/updater.go159
-rwxr-xr-xscripts/update-rulesets.sh37
46 files changed, 4821 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ab7a93
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+data/rules/*.srs
+data/rules/*.txt
+*.exe
+*.dll
+/vpnem-server
+/vpnem-client
+/build/
+/server
+/client
+cmd/client/build/bin/
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..778918e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM golang:1.25-alpine AS builder
+WORKDIR /build
+COPY go.mod ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o vpnem-server ./cmd/server
+
+FROM alpine:3.21
+RUN apk add --no-cache wget ca-certificates
+WORKDIR /opt/vpnem
+COPY --from=builder /build/vpnem-server .
+COPY data/ ./data/
+COPY scripts/update-rulesets.sh ./scripts/
+RUN chmod +x scripts/update-rulesets.sh && mkdir -p data/rules
+
+EXPOSE 8090
+CMD ["./vpnem-server", "-addr", ":8090", "-data", "./data"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f3051ae
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,60 @@
+GOPATH ?= $(HOME)/.local/share/go
+WAILS := $(GOPATH)/bin/wails
+SERVER := root@178.20.40.99
+export GOPATH
+
+.PHONY: all server client-linux client-windows installer test clean deploy release
+
+all: server client-linux
+
+# --- Server ---
+
+server:
+ go build -o build/vpnem-server ./cmd/server
+
+# --- Client ---
+
+client-linux:
+ cd cmd/client && $(WAILS) build -platform linux/amd64 -o vpnem -tags with_gvisor
+ cp cmd/client/build/bin/vpnem build/vpnem-linux-amd64
+
+client-windows:
+ cd cmd/client && CC=x86_64-w64-mingw32-gcc $(WAILS) build -platform windows/amd64 -o vpnem.exe -tags with_gvisor
+ cp cmd/client/build/bin/vpnem.exe build/vpnem-windows-amd64.exe
+
+installer:
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui" -o build/vpnem-installer.exe ./cmd/installer
+
+# --- Test ---
+
+test:
+ go test ./... -v
+
+# --- Deploy: server code only ---
+
+deploy:
+ rsync -avz --exclude '.git' --exclude 'build' --exclude '*.srs' --exclude '.claude' \
+ ./ $(SERVER):/opt/vpnem/
+ ssh $(SERVER) "cd /opt/vpnem && docker compose up -d --build"
+ @echo "Verify: curl https://vpn.em-sysadmin.xyz/health"
+
+# --- Release: build all + deploy binaries + bump version ---
+
+release: client-linux client-windows installer
+ rm -f build/vpnem-linux-amd64
+ cp cmd/client/build/bin/vpnem build/vpnem-linux-amd64
+ scp build/vpnem-linux-amd64 build/vpnem-windows-amd64.exe build/vpnem-installer.exe \
+ $(SERVER):/opt/vpnem/data/releases/
+ scp data/version.json $(SERVER):/opt/vpnem/data/version.json
+ rsync -avz --exclude '.git' --exclude 'build' --exclude '*.srs' --exclude '.claude' \
+ ./ $(SERVER):/opt/vpnem/
+ ssh $(SERVER) "cd /opt/vpnem && docker compose restart"
+ @echo ""
+ @curl -s https://vpn.em-sysadmin.xyz/api/v1/version
+ @echo ""
+ @echo "Release deployed."
+
+# --- Clean ---
+
+clean:
+ rm -rf build/ cmd/client/build/
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
new file mode 100644
index 0000000..63617fe
--- /dev/null
+++ b/cmd/client/build/appicon.png
Binary files differ
diff --git a/cmd/client/build/windows/icon.ico b/cmd/client/build/windows/icon.ico
new file mode 100644
index 0000000..bfa0690
--- /dev/null
+++ b/cmd/client/build/windows/icon.ico
Binary files differ
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)
+ }
+}
diff --git a/data/rulesets.json b/data/rulesets.json
new file mode 100644
index 0000000..58c72ca
--- /dev/null
+++ b/data/rulesets.json
@@ -0,0 +1,52 @@
+{
+ "rule_sets": [
+ {
+ "tag": "refilter-domains",
+ "description": "Заблокированные домены РФ (Re-filter)",
+ "url": "https://vpn.em-sysadmin.xyz/rules/refilter-domains.srs",
+ "format": "binary",
+ "type": "domain",
+ "optional": false
+ },
+ {
+ "tag": "refilter-ip",
+ "description": "Заблокированные IP РФ (Re-filter)",
+ "url": "https://vpn.em-sysadmin.xyz/rules/refilter-ip.srs",
+ "format": "binary",
+ "type": "ip",
+ "optional": false
+ },
+ {
+ "tag": "discord-voice",
+ "description": "Discord voice server IPs",
+ "url": "https://vpn.em-sysadmin.xyz/rules/discord-voice-ip-list.srs",
+ "format": "binary",
+ "type": "ip",
+ "optional": false
+ },
+ {
+ "tag": "antizapret",
+ "description": "Расширенный список блокировок (AntiZapret)",
+ "url": "",
+ "format": "binary",
+ "type": "domain",
+ "optional": true
+ },
+ {
+ "tag": "itdoginfo-inside-russia",
+ "description": "Российские ресурсы (для direct маршрута)",
+ "url": "",
+ "format": "binary",
+ "type": "domain",
+ "optional": true
+ },
+ {
+ "tag": "torrent-clients",
+ "description": "Торрент-клиенты",
+ "url": "",
+ "format": "binary",
+ "type": "ip",
+ "optional": true
+ }
+ ]
+}
diff --git a/data/servers.json b/data/servers.json
new file mode 100644
index 0000000..89744f0
--- /dev/null
+++ b/data/servers.json
@@ -0,0 +1,19 @@
+{
+ "servers": [
+ {"tag": "nl-1", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-2", "region": "NL", "type": "socks", "server": "5.180.97.200", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-3", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-4", "region": "NL", "type": "socks", "server": "5.180.97.199", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-5", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-6", "region": "NL", "type": "socks", "server": "5.180.97.198", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-7", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-8", "region": "NL", "type": "socks", "server": "5.180.97.197", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-9", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "nl-10", "region": "NL", "type": "socks", "server": "5.180.97.181", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "ru-1", "region": "RU", "type": "socks", "server": "84.252.100.166", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "ru-2", "region": "RU", "type": "socks", "server": "84.252.100.165", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "ru-3", "region": "RU", "type": "socks", "server": "84.252.100.161", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "ru-4", "region": "RU", "type": "socks", "server": "84.252.100.117", "server_port": 54101, "udp_over_tcp": true},
+ {"tag": "ru-5", "region": "RU", "type": "socks", "server": "84.252.100.103", "server_port": 54101, "udp_over_tcp": true}
+ ]
+}
diff --git a/data/version.json b/data/version.json
new file mode 100644
index 0000000..614479b
--- /dev/null
+++ b/data/version.json
@@ -0,0 +1 @@
+{"version":"2.0.6","url":"","changelog":"Bypass em-sysadmin.xyz domains to prevent double proxying"}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1e962fe
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+networks:
+ web:
+ external: true
+
+services:
+ server:
+ build: .
+ restart: unless-stopped
+ volumes:
+ - ./data:/opt/vpnem/data
+ networks: [web]
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=web
+ - traefik.http.routers.vpnem.rule=Host(`vpn.em-sysadmin.xyz`)
+ - traefik.http.routers.vpnem.entrypoints=websecure
+ - traefik.http.routers.vpnem.tls.certresolver=le
+ - traefik.http.services.vpnem.loadbalancer.server.port=8090
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..42f3e5f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,114 @@
+module vpnem
+
+go 1.25.0
+
+require (
+ github.com/sagernet/sing-box v1.11.0
+ github.com/wailsapp/wails/v2 v2.12.0
+)
+
+require (
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
+ github.com/ajg/form v1.5.1 // indirect
+ github.com/andybalholm/brotli v1.0.6 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/caddyserver/certmagic v0.20.0 // indirect
+ github.com/cloudflare/circl v1.3.7 // indirect
+ github.com/cretz/bine v0.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-chi/chi/v5 v5.1.0 // indirect
+ github.com/go-chi/render v1.0.3 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
+ github.com/gofrs/uuid/v5 v5.3.0 // indirect
+ github.com/google/btree v1.1.3 // indirect
+ github.com/google/go-cmp v0.6.0 // indirect
+ github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+ github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/josharian/native v1.1.0 // indirect
+ github.com/klauspost/compress v1.17.4 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.5 // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/libdns/alidns v1.0.3 // indirect
+ github.com/libdns/cloudflare v0.1.1 // indirect
+ github.com/libdns/libdns v0.2.2 // indirect
+ github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mdlayher/netlink v1.7.2 // indirect
+ github.com/mdlayher/socket v0.4.1 // indirect
+ github.com/metacubex/tfo-go v0.0.0-20241006021335-daedaf0ca7aa // indirect
+ github.com/mholt/acmez v1.2.0 // indirect
+ github.com/miekg/dns v1.1.62 // indirect
+ github.com/onsi/ginkgo/v2 v2.9.7 // indirect
+ github.com/oschwald/maxminddb-golang v1.12.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.14 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/quic-go/qpack v0.4.0 // indirect
+ github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
+ github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 // indirect
+ github.com/sagernet/cors v1.2.1 // indirect
+ github.com/sagernet/fswatch v0.1.1 // indirect
+ github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff // indirect
+ github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
+ github.com/sagernet/nftables v0.3.0-beta.4 // indirect
+ github.com/sagernet/quic-go v0.48.2-beta.1 // indirect
+ github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 // indirect
+ github.com/sagernet/sing v0.6.0-beta.12 // indirect
+ github.com/sagernet/sing-dns v0.4.0-beta.2 // indirect
+ github.com/sagernet/sing-mux v0.3.0-alpha.1 // indirect
+ github.com/sagernet/sing-quic v0.4.0-beta.4 // indirect
+ github.com/sagernet/sing-shadowsocks v0.2.7 // indirect
+ github.com/sagernet/sing-shadowsocks2 v0.2.0 // indirect
+ github.com/sagernet/sing-shadowtls v0.2.0-alpha.2 // indirect
+ github.com/sagernet/sing-tun v0.6.0-beta.8 // indirect
+ github.com/sagernet/sing-vmess v0.2.0-beta.2 // indirect
+ github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 // indirect
+ github.com/sagernet/utls v1.6.7 // indirect
+ github.com/sagernet/wireguard-go v0.0.1-beta.5 // indirect
+ github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/vishvananda/netns v0.0.4 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ github.com/zeebo/blake3 v0.2.3 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
+ golang.org/x/time v0.8.0 // indirect
+ golang.org/x/tools v0.41.0 // indirect
+ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect
+ google.golang.org/grpc v1.63.2 // indirect
+ google.golang.org/protobuf v1.33.0 // indirect
+ lukechampine.com/blake3 v1.3.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..aeaf9bf
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,262 @@
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
+github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
+github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
+github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/caddyserver/certmagic v0.20.0 h1:bTw7LcEZAh9ucYCRXyCpIrSAGplplI0vGYJ4BpCQ/Fc=
+github.com/caddyserver/certmagic v0.20.0/go.mod h1:N4sXgpICQUskEWpj7zVzvWD41p3NYacrNoZYiRM2jTg=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
+github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
+github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk=
+github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a h1:fEBsGL/sjAuJrgah5XqmmYsTLzJp/TO9Lhy39gkverk=
+github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
+github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
+github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
+github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
+github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
+github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
+github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/libdns/alidns v1.0.3 h1:LFHuGnbseq5+HCeGa1aW8awyX/4M2psB9962fdD2+yQ=
+github.com/libdns/alidns v1.0.3/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE=
+github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054=
+github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU=
+github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
+github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
+github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
+github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
+github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
+github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
+github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
+github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
+github.com/metacubex/tfo-go v0.0.0-20241006021335-daedaf0ca7aa h1:9mcjV+RGZVC3reJBNDjjNPyS8PmFG97zq56X7WNaFO4=
+github.com/metacubex/tfo-go v0.0.0-20241006021335-daedaf0ca7aa/go.mod h1:4tLB5c8U0CxpkFM+AJJB77jEaVDbLH5XQvy42vAGsWw=
+github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
+github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE=
+github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
+github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
+github.com/onsi/ginkgo/v2 v2.9.7 h1:06xGQy5www2oN160RtEZoTvnP2sPhEfePYmCDc2szss=
+github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0=
+github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU=
+github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4=
+github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
+github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
+github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
+github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
+github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
+github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs=
+github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0=
+github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
+github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 h1:YbmpqPQEMdlk9oFSKYWRqVuu9qzNiOayIonKmv1gCXY=
+github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1/go.mod h1:J2yAxTFPDjrDPhuAi9aWFz2L3ox9it4qAluBBbN0H5k=
+github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
+github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
+github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
+github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
+github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff h1:mlohw3360Wg1BNGook/UHnISXhUx4Gd/3tVLs5T0nSs=
+github.com/sagernet/gvisor v0.0.0-20241123041152-536d05261cff/go.mod h1:ehZwnT2UpmOWAHFL48XdBhnd4Qu4hN2O3Ji0us3ZHMw=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
+github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I=
+github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8=
+github.com/sagernet/quic-go v0.48.2-beta.1 h1:W0plrLWa1XtOWDTdX3CJwxmQuxkya12nN5BRGZ87kEg=
+github.com/sagernet/quic-go v0.48.2-beta.1/go.mod h1:1WgdDIVD1Gybp40JTWketeSfKA/+or9YMLaG5VeTk4k=
+github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 h1:5Th31OC6yj8byLGkEnIYp6grlXfo1QYUfiYFGjewIdc=
+github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691/go.mod h1:B8lp4WkQ1PwNnrVMM6KyuFR20pU8jYBD+A4EhJovEXU=
+github.com/sagernet/sing v0.2.18/go.mod h1:OL6k2F0vHmEzXz2KW19qQzu172FDgSbUSODylighuVo=
+github.com/sagernet/sing v0.6.0-beta.12 h1:2DnTJcvypK3/PM/8JjmgG8wVK48gdcpRwU98c4J/a7s=
+github.com/sagernet/sing v0.6.0-beta.12/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
+github.com/sagernet/sing-box v1.11.0 h1:70DvEMhFMtDn4YQdj3aoOsRdvUznLGQREnORAt6UmYo=
+github.com/sagernet/sing-box v1.11.0/go.mod h1:DmL1WKyrfaAEu5z88CtUeQBfELaEdUyQzLS5nzmRg8o=
+github.com/sagernet/sing-dns v0.4.0-beta.2 h1:HW94bUEp7K/vf5DlYz646LTZevQtJ0250jZa/UZRlbY=
+github.com/sagernet/sing-dns v0.4.0-beta.2/go.mod h1:8wuFcoFkWM4vJuQyg8e97LyvDwe0/Vl7G839WLcKDs8=
+github.com/sagernet/sing-mux v0.3.0-alpha.1 h1:IgNX5bJBpL41gGbp05pdDOvh/b5eUQ6cv9240+Ngipg=
+github.com/sagernet/sing-mux v0.3.0-alpha.1/go.mod h1:FTcImmdfW38Lz7b+HQ+mxxOth1lz4ao8uEnz+MwIJQE=
+github.com/sagernet/sing-quic v0.4.0-beta.4 h1:kKiMLGaxvVLDCSvCMYo4PtWd1xU6FTL7xvUAQfXO09g=
+github.com/sagernet/sing-quic v0.4.0-beta.4/go.mod h1:1UNObFodd8CnS3aCT53x9cigjPSCl3P//8dfBMCwBDM=
+github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8=
+github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE=
+github.com/sagernet/sing-shadowsocks2 v0.2.0 h1:wpZNs6wKnR7mh1wV9OHwOyUr21VkS3wKFHi+8XwgADg=
+github.com/sagernet/sing-shadowsocks2 v0.2.0/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
+github.com/sagernet/sing-shadowtls v0.2.0-alpha.2 h1:RPrpgAdkP5td0vLfS5ldvYosFjSsZtRPxiyLV6jyKg0=
+github.com/sagernet/sing-shadowtls v0.2.0-alpha.2/go.mod h1:0j5XlzKxaWRIEjc1uiSKmVoWb0k+L9QgZVb876+thZA=
+github.com/sagernet/sing-tun v0.6.0-beta.8 h1:GFNt/w8r1v30zC/hfCytk8C9+N/f1DfvosFXJkyJlrw=
+github.com/sagernet/sing-tun v0.6.0-beta.8/go.mod h1:fisFCbC4Vfb6HqQNcwPJi2CDK2bf0Xapyz3j3t4cnHE=
+github.com/sagernet/sing-vmess v0.2.0-beta.2 h1:obAkAL35X7ql4RnGzDg4dBYIRpGXRKqcN4LyLZpZGSs=
+github.com/sagernet/sing-vmess v0.2.0-beta.2/go.mod h1:HGhf9XUdeE2iOWrX0hQNFgXPbKyGlzpeYFyX0c/pykk=
+github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 h1:DImB4lELfQhplLTxeq2z31Fpv8CQqqrUwTbrIRumZqQ=
+github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7/go.mod h1:FP9X2xjT/Az1EsG/orYYoC+5MojWnuI7hrffz8fGwwo=
+github.com/sagernet/utls v1.6.7 h1:Ep3+aJ8FUGGta+II2IEVNUc3EDhaRCZINWkj/LloIA8=
+github.com/sagernet/utls v1.6.7/go.mod h1:Uua1TKO/FFuAhLr9rkaVnnrTmmiItzDjv1BUb2+ERwM=
+github.com/sagernet/wireguard-go v0.0.1-beta.5 h1:aBEsxJUMEONwOZqKPIkuAcv4zJV5p6XlzEN04CF0FXc=
+github.com/sagernet/wireguard-go v0.0.1-beta.5/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo=
+github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
+github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
+github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
+github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
+github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
+github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
+go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
+golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY=
+google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
+google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
+lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
new file mode 100644
index 0000000..ae2e15c
--- /dev/null
+++ b/internal/api/handlers.go
@@ -0,0 +1,54 @@
+package api
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+
+ "vpnem/internal/rules"
+)
+
+type Handler struct {
+ store *rules.Store
+}
+
+func NewHandler(store *rules.Store) *Handler {
+ return &Handler{store: store}
+}
+
+func (h *Handler) Servers(w http.ResponseWriter, r *http.Request) {
+ servers, err := h.store.LoadServers()
+ if err != nil {
+ log.Printf("error loading servers: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, servers)
+}
+
+func (h *Handler) RuleSetManifest(w http.ResponseWriter, r *http.Request) {
+ manifest, err := h.store.LoadRuleSets()
+ if err != nil {
+ log.Printf("error loading rulesets: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, manifest)
+}
+
+func (h *Handler) Version(w http.ResponseWriter, r *http.Request) {
+ ver, err := h.store.LoadVersion()
+ if err != nil {
+ log.Printf("error loading version: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, ver)
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("error encoding json: %v", err)
+ }
+}
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
new file mode 100644
index 0000000..942a249
--- /dev/null
+++ b/internal/api/handlers_test.go
@@ -0,0 +1,129 @@
+package api_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "vpnem/internal/api"
+ "vpnem/internal/models"
+ "vpnem/internal/rules"
+)
+
+func setupTestStore(t *testing.T) *rules.Store {
+ t.Helper()
+ dir := t.TempDir()
+
+ writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{
+ Servers: []models.Server{
+ {Tag: "test-1", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080},
+ },
+ })
+
+ writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{
+ RuleSets: []models.RuleSet{
+ {Tag: "test-rules", Description: "test", URL: "https://example.com/test.srs", Format: "binary", Type: "domain"},
+ },
+ })
+
+ writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{
+ Version: "0.1.0", Changelog: "test",
+ })
+
+ os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
+
+ return rules.NewStore(dir)
+}
+
+func writeJSON(t *testing.T, path string, v any) {
+ t.Helper()
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/servers", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.ServersResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if len(resp.Servers) != 1 {
+ t.Fatalf("expected 1 server, got %d", len(resp.Servers))
+ }
+ if resp.Servers[0].Tag != "test-1" {
+ t.Errorf("expected tag test-1, got %s", resp.Servers[0].Tag)
+ }
+}
+
+func TestRuleSetManifestEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/ruleset/manifest", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.RuleSetManifest
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if len(resp.RuleSets) != 1 {
+ t.Fatalf("expected 1 ruleset, got %d", len(resp.RuleSets))
+ }
+}
+
+func TestVersionEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/version", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.VersionResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if resp.Version != "0.1.0" {
+ t.Errorf("expected version 0.1.0, got %s", resp.Version)
+ }
+}
+
+func TestMethodNotAllowed(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("POST", "/api/v1/servers", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code == http.StatusOK {
+ t.Fatal("POST should not return 200")
+ }
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..717236c
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,30 @@
+package api
+
+import (
+ "net/http"
+
+ "vpnem/internal/rules"
+)
+
+func NewRouter(store *rules.Store) http.Handler {
+ h := NewHandler(store)
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"status":"ok"}`))
+ })
+ mux.HandleFunc("GET /api/v1/servers", h.Servers)
+ mux.HandleFunc("GET /api/v1/ruleset/manifest", h.RuleSetManifest)
+ mux.HandleFunc("GET /api/v1/version", h.Version)
+
+ // Static file serving for .srs and .txt rule files
+ rulesFS := http.StripPrefix("/rules/", http.FileServer(http.Dir(store.RulesDir())))
+ mux.Handle("/rules/", rulesFS)
+
+ // Static file serving for client releases
+ releasesFS := http.StripPrefix("/releases/", http.FileServer(http.Dir(store.ReleasesDir())))
+ mux.Handle("/releases/", releasesFS)
+
+ return mux
+}
diff --git a/internal/config/builder.go b/internal/config/builder.go
new file mode 100644
index 0000000..a9e513b
--- /dev/null
+++ b/internal/config/builder.go
@@ -0,0 +1,182 @@
+package config
+
+import (
+ "vpnem/internal/models"
+)
+
+type SingBoxConfig struct {
+ DNS map[string]any `json:"dns"`
+ Inbounds []map[string]any `json:"inbounds"`
+ Outbounds []map[string]any `json:"outbounds"`
+ Route map[string]any `json:"route"`
+ Experimental map[string]any `json:"experimental,omitempty"`
+}
+
+var blockedDomains = []string{
+ // Telegram
+ "telegram.org", "t.me", "telegram.me", "telegra.ph", "telegram.dog",
+ "web.telegram.org",
+ // Discord
+ "discord.com", "discord.gg", "discordapp.com", "discordapp.net",
+ // Meta
+ "instagram.com", "cdninstagram.com", "ig.me", "igcdn.com",
+ "facebook.com", "fb.com", "fbcdn.net", "fbsbx.com", "fb.me",
+ "whatsapp.com", "whatsapp.net",
+ // Twitter/X
+ "twitter.com", "x.com", "twimg.com", "t.co",
+ // AI
+ "openai.com", "chatgpt.com", "oaistatic.com", "oaiusercontent.com",
+ "claude.ai", "anthropic.com",
+ // YouTube/Google
+ "youtube.com", "googlevideo.com", "youtu.be", "ggpht.com", "ytimg.com",
+ "gstatic.com", "doubleclick.net", "googleadservices.com",
+ // Cam sites
+ "stripchat.com", "stripchat.global", "ststandard.com", "strpssts-ana.com",
+ "strpst.com", "striiiipst.com",
+ "chaturbate.com", "highwebmedia.com", "cb.dev",
+ "camsoda.com", "cam4.com", "cam101.com",
+ "bongamodels.com", "flirt4free.com", "privatecams.com",
+ "streamray.com", "cams.com", "homelivesex.com",
+ "skyprivate.com", "mywebcamroom.com", "livemediahost.com",
+ // Cam CDNs
+ "xcdnpro.com", "mmcdn.com", "vscdns.com", "bgicdn.com", "bgmicdn.com",
+ "doppiocdn.com", "doppiocdn.net", "doppiostreams.com",
+ "fanclubs.tech", "my.club", "chapturist.com",
+ // Cam analytics/services
+ "moengage.com", "amplitude.com", "dwin1.com",
+ "eizzih.com", "loo3laej.com", "iesnare.com",
+ "hytto.com", "zendesk.com",
+ // Lovense
+ "lovense.com", "lovense-api.com", "lovense.club",
+ // Bitrix
+ "bitrix24.ru", "bitrix24.com",
+ // Cloudflare
+ "cloudflare.com",
+ // Other blocked
+ "viber.com", "linkedin.com", "spotify.com",
+ "ntc.party", "ipify.org",
+ "rutracker.org", "rutracker.net", "rutracker.me",
+ "4pda.to", "kinozal.tv", "nnmclub.to",
+ "protonmail.com", "proton.me", "tutanota.com",
+ "medium.com", "archive.org", "soundcloud.com", "twitch.tv",
+ // IP check
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io",
+ // Email
+ "em-mail.ru",
+}
+
+func BuildConfig(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string) SingBoxConfig {
+ return BuildConfigFull(server, mode, ruleSets, serverIPs, nil)
+}
+
+// BuildConfigFull — exact vpn.py config. Fast, proven.
+func BuildConfigFull(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string) SingBoxConfig {
+ bypassIPs := BuildBypassIPs(serverIPs)
+ bypassProcs := BuildBypassProcesses(customBypass)
+
+ var rules []map[string]any
+ rules = append(rules, map[string]any{"ip_cidr": bypassIPs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"process_name": bypassProcs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": LocalDomainSuffixes, "outbound": "direct"})
+ // Bypass own infrastructure — prevent double proxying when OmegaSwitcher is also active
+ rules = append(rules, map[string]any{"domain_suffix": []string{"em-sysadmin.xyz"}, "outbound": "direct"})
+ rules = append(rules, map[string]any{"process_path_regex": LovenseProcessRegex, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"ip_cidr": ForcedProxyIPs, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_suffix": TelegramDomains, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_regex": TelegramDomainRegex, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"ip_cidr": TelegramIPs, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_suffix": blockedDomains, "outbound": "proxy"})
+
+ for _, r := range mode.Rules {
+ rule := map[string]any{"outbound": r.Outbound}
+ if len(r.DomainSuffix) > 0 { rule["domain_suffix"] = r.DomainSuffix }
+ if len(r.DomainRegex) > 0 { rule["domain_regex"] = r.DomainRegex }
+ if len(r.IPCIDR) > 0 { rule["ip_cidr"] = r.IPCIDR }
+ if len(r.RuleSet) > 0 { rule["rule_set"] = r.RuleSet }
+ if len(r.Network) > 0 { rule["network"] = r.Network }
+ if len(r.PortRange) > 0 { rule["port_range"] = r.PortRange }
+ rules = append(rules, rule)
+ }
+
+ var ruleSetDefs []map[string]any
+ for _, rs := range ruleSets {
+ if rs.URL == "" { continue }
+ ruleSetDefs = append(ruleSetDefs, map[string]any{
+ "tag": rs.Tag, "type": "remote", "format": rs.Format,
+ "url": rs.URL, "download_detour": "direct", "update_interval": "1d",
+ })
+ }
+
+ route := map[string]any{
+ "auto_detect_interface": true,
+ "final": mode.Final,
+ "rules": rules,
+ }
+ if len(ruleSetDefs) > 0 {
+ route["rule_set"] = ruleSetDefs
+ }
+
+ return SingBoxConfig{
+ DNS: map[string]any{
+ "servers": []map[string]any{
+ {"tag": "proxy-dns", "address": "https://8.8.8.8/dns-query", "detour": "proxy"},
+ {"tag": "direct-dns", "address": "https://1.1.1.1/dns-query", "detour": "direct"},
+ },
+ "rules": []map[string]any{
+ {"outbound": "proxy", "server": "proxy-dns"},
+ {"outbound": "direct", "server": "direct-dns"},
+ },
+ "strategy": "ipv4_only",
+ },
+ Inbounds: []map[string]any{
+ {
+ "type": "tun",
+ "interface_name": "singbox",
+ "address": []string{"172.19.0.1/30"},
+ "auto_route": true,
+ "strict_route": false,
+ "stack": "gvisor",
+ "sniff": true,
+ "sniff_override_destination": true,
+ },
+ },
+ Outbounds: []map[string]any{
+ buildOutbound(server),
+ {"type": "direct", "tag": "direct"},
+ },
+ Route: route,
+ Experimental: map[string]any{
+ "cache_file": map[string]any{
+ "enabled": true,
+ "path": "cache.db",
+ },
+ },
+ }
+}
+
+
+
+func buildOutbound(s models.Server) map[string]any {
+ switch s.Type {
+ case "vless":
+ out := map[string]any{
+ "type": "vless", "tag": "proxy",
+ "server": s.Server, "server_port": s.ServerPort, "uuid": s.UUID,
+ }
+ if s.TLS != nil { out["tls"] = map[string]any{"enabled": s.TLS.Enabled, "server_name": s.TLS.ServerName} }
+ if s.Transport != nil { out["transport"] = map[string]any{"type": s.Transport.Type, "path": s.Transport.Path} }
+ return out
+ case "shadowsocks":
+ return map[string]any{
+ "type": "shadowsocks", "tag": "proxy",
+ "server": s.Server, "server_port": s.ServerPort,
+ "method": s.Method, "password": s.Password,
+ }
+ default:
+ return map[string]any{
+ "type": "socks", "tag": "proxy",
+ "server": s.Server, "server_port": s.ServerPort,
+ "udp_over_tcp": s.UDPOverTCP,
+ }
+ }
+}
diff --git a/internal/config/builder_test.go b/internal/config/builder_test.go
new file mode 100644
index 0000000..d5c0cb5
--- /dev/null
+++ b/internal/config/builder_test.go
@@ -0,0 +1,232 @@
+package config_test
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+func TestBuildConfigSocks(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-1", Region: "NL", Type: "socks",
+ Server: "5.180.97.200", ServerPort: 54101, UDPOverTCP: true,
+ }
+ mode := *config.ModeByName("Lovense + OBS + AnyDesk + Discord")
+ ruleSets := []models.RuleSet{}
+
+ cfg := config.BuildConfig(server, mode, ruleSets, []string{"5.180.97.200"})
+
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ s := string(data)
+
+ // Verify outbound type
+ if !strings.Contains(s, `"type":"socks"`) {
+ t.Error("expected socks outbound")
+ }
+ // Verify bypass processes present
+ if !strings.Contains(s, "chrome.exe") {
+ t.Error("expected chrome.exe in bypass processes")
+ }
+ if !strings.Contains(s, "obs64.exe") {
+ t.Error("expected obs64.exe in bypass processes")
+ }
+ // Verify Lovense regex
+ if !strings.Contains(s, "lovense") {
+ t.Error("expected lovense process regex")
+ }
+ // Verify ip_is_private
+ if !strings.Contains(s, "ip_is_private") {
+ t.Error("expected ip_is_private rule")
+ }
+ // Verify NCSI domains
+ if !strings.Contains(s, "msftconnecttest.com") {
+ t.Error("expected NCSI domain")
+ }
+ // Verify Telegram
+ if !strings.Contains(s, "telegram.org") {
+ t.Error("expected telegram domains")
+ }
+ // Verify Discord IPs
+ if !strings.Contains(s, "162.159.130.234/32") {
+ t.Error("expected discord IPs")
+ }
+ // Verify final is direct
+ if !strings.Contains(s, `"final":"direct"`) {
+ t.Error("expected final: direct")
+ }
+ // Verify TUN config
+ if !strings.Contains(s, "singbox") {
+ t.Error("expected TUN interface name singbox")
+ }
+ // Verify DNS
+ if !strings.Contains(s, "dns-proxy") {
+ t.Error("expected dns-proxy server")
+ }
+ // Verify cache_file
+ if !strings.Contains(s, "cache_file") {
+ t.Error("expected cache_file in experimental")
+ }
+ // sing-box 1.13: sniff action in route rules, not inbound
+ if strings.Contains(s, `"sniff":true`) {
+ t.Error("sniff should NOT be in inbound (removed in 1.13)")
+ }
+ if !strings.Contains(s, `"action":"sniff"`) {
+ t.Error("expected action:sniff in route rules")
+ }
+ // sing-box 1.13: hijack-dns action
+ if !strings.Contains(s, `"action":"hijack-dns"`) {
+ t.Error("expected hijack-dns action")
+ }
+ // sing-box 1.12: new DNS server format (type+server, not address)
+ if strings.Contains(s, `dns-query`) {
+ t.Error("DNS should use type+server, not address URL (deprecated in 1.12)")
+ }
+ if !strings.Contains(s, `"type":"https"`) {
+ t.Error("expected https DNS server")
+ }
+ if !strings.Contains(s, `"1.1.1.1"`) {
+ t.Error("expected 1.1.1.1 DoH server")
+ }
+ // sing-box 1.12: domain_resolver on outbounds
+ if !strings.Contains(s, "domain_resolver") {
+ t.Error("expected domain_resolver on outbounds")
+ }
+}
+
+func TestBuildConfigVLESS(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-vless", Region: "NL", Type: "vless",
+ Server: "5.180.97.200", ServerPort: 443, UUID: "test-uuid",
+ TLS: &models.TLS{Enabled: true, ServerName: "test.example.com"},
+ Transport: &models.Transport{Type: "ws", Path: "/test"},
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"vless"`) {
+ t.Error("expected vless outbound")
+ }
+ if !strings.Contains(s, "test-uuid") {
+ t.Error("expected uuid")
+ }
+ if !strings.Contains(s, `"final":"proxy"`) {
+ t.Error("expected final: proxy for Full mode")
+ }
+}
+
+func TestBuildConfigShadowsocks(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-ss", Region: "NL", Type: "shadowsocks",
+ Server: "5.180.97.200", ServerPort: 36728,
+ Method: "chacha20-ietf-poly1305", Password: "test-pass",
+ }
+ mode := *config.ModeByName("Discord Only")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"shadowsocks"`) {
+ t.Error("expected shadowsocks outbound")
+ }
+ if !strings.Contains(s, "chacha20-ietf-poly1305") {
+ t.Error("expected method")
+ }
+}
+
+func TestBuildConfigWithRuleSets(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-1", Type: "socks", Server: "1.2.3.4", ServerPort: 1080,
+ }
+ mode := *config.ModeByName("Re-filter (обход блокировок РФ)")
+ ruleSets := []models.RuleSet{
+ {Tag: "refilter-domains", URL: "https://example.com/domains.srs", Format: "binary"},
+ {Tag: "refilter-ip", URL: "https://example.com/ip.srs", Format: "binary"},
+ {Tag: "discord-voice", URL: "https://example.com/discord.srs", Format: "binary"},
+ }
+
+ cfg := config.BuildConfig(server, mode, ruleSets, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, "refilter-domains") {
+ t.Error("expected refilter-domains rule_set")
+ }
+ if !strings.Contains(s, "download_detour") {
+ t.Error("expected download_detour in rule_set")
+ }
+ if !strings.Contains(s, "update_interval") {
+ t.Error("expected update_interval in rule_set")
+ }
+}
+
+func TestBuildBypassIPs(t *testing.T) {
+ ips := config.BuildBypassIPs([]string{"1.2.3.4", "5.180.97.200"})
+
+ found := false
+ for _, ip := range ips {
+ if ip == "1.2.3.4/32" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected dynamic server IP in bypass list")
+ }
+
+ // 5.180.97.200 is already in StaticBypassIPs, should not be duplicated
+ count := 0
+ for _, ip := range ips {
+ if ip == "5.180.97.200/32" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Errorf("expected 5.180.97.200/32 exactly once, got %d", count)
+ }
+}
+
+func TestAllModes(t *testing.T) {
+ modes := config.AllModes()
+ if len(modes) != 7 {
+ t.Errorf("expected 7 modes, got %d", len(modes))
+ }
+
+ names := config.ModeNames()
+ expected := []string{
+ "Lovense + OBS + AnyDesk",
+ "Lovense + OBS + AnyDesk + Discord",
+ "Lovense + OBS + AnyDesk + Discord + Teams",
+ "Discord Only",
+ "Full (All Traffic)",
+ "Re-filter (обход блокировок РФ)",
+ "Комбо (приложения + Re-filter)",
+ }
+ for i, name := range expected {
+ if names[i] != name {
+ t.Errorf("mode %d: expected %q, got %q", i, name, names[i])
+ }
+ }
+}
+
+func TestModeByName(t *testing.T) {
+ m := config.ModeByName("Full (All Traffic)")
+ if m == nil {
+ t.Fatal("expected to find Full mode")
+ }
+ if m.Final != "proxy" {
+ t.Errorf("Full mode final should be proxy, got %s", m.Final)
+ }
+
+ if config.ModeByName("nonexistent") != nil {
+ t.Error("expected nil for nonexistent mode")
+ }
+}
diff --git a/internal/config/bypass.go b/internal/config/bypass.go
new file mode 100644
index 0000000..6232af0
--- /dev/null
+++ b/internal/config/bypass.go
@@ -0,0 +1,139 @@
+package config
+
+// BYPASS_PROCESSES — processes that go direct, bypassing TUN.
+// Ported 1:1 from vpn.py.
+var BypassProcesses = []string{
+ "QTranslate.exe",
+ "aspia_host.exe",
+ "aspia_host_service.exe",
+ "aspia_desktop_agent.exe",
+ "chrome.exe",
+ "firefox.exe",
+ "Performer Application v5.x.exe",
+ "chromium.exe",
+ "msedgewebview2.exe",
+ "Яндекс Музыка.exe",
+ "obs64.exe",
+}
+
+// LovenseProcessRegex — force Lovense through proxy regardless of mode.
+var LovenseProcessRegex = []string{"(?i).*lovense.*"}
+
+// BYPASS_IPS — VPN server IPs + service IPs, always direct.
+// NL servers, RU servers, misc.
+var StaticBypassIPs = []string{
+ // NL servers
+ "5.180.97.200/32", "5.180.97.199/32", "5.180.97.198/32",
+ "5.180.97.197/32", "5.180.97.181/32",
+ // RU servers
+ "84.252.100.166/32", "84.252.100.165/32", "84.252.100.161/32",
+ "84.252.100.117/32", "84.252.100.103/32",
+ // Misc
+ "109.107.175.41/32", "146.103.104.48/32", "77.105.138.163/32",
+ "91.84.113.225/32", "146.103.98.171/32", "94.103.88.252/32",
+ "178.20.44.93/32", "89.124.70.47/32",
+}
+
+// ReservedCIDRs — ranges not covered by ip_is_private.
+var ReservedCIDRs = []string{
+ "100.64.0.0/10", // CGNAT / Tailscale
+ "192.0.0.0/24", // IETF protocol assignments
+ "192.0.2.0/24", // TEST-NET-1
+ "198.51.100.0/24", // TEST-NET-2
+ "203.0.113.0/24", // TEST-NET-3
+ "240.0.0.0/4", // Reserved (Class E)
+ "255.255.255.255/32", // Broadcast
+}
+
+// LocalDomainSuffixes — local/mDNS domains, always direct.
+var LocalDomainSuffixes = []string{
+ "local", "localhost", "lan", "internal", "home.arpa",
+ "corp", "intranet", "test", "invalid", "example",
+ "home", "localdomain",
+}
+
+// WindowsNCSIDomains — Windows Network Connectivity Status Indicator.
+// Without these going direct, Windows shows "No Internet" warnings.
+var WindowsNCSIDomains = []string{
+ "msftconnecttest.com",
+ "msftncsi.com",
+}
+
+// ForcedProxyIPs — IPs that must always go through proxy.
+var ForcedProxyIPs = []string{
+ "65.21.33.248/32",
+ "91.132.135.38/32",
+}
+
+// Telegram — hardcoded, applied to ALL modes.
+var TelegramDomains = []string{
+ "telegram.org", "telegram.me", "t.me", "telegra.ph", "telegram.dog",
+}
+
+var TelegramDomainRegex = []string{
+ ".*telegram.*", `.*t\.me.*`,
+}
+
+var TelegramIPs = []string{
+ "91.108.56.0/22", "91.108.4.0/22", "91.108.8.0/22",
+ "91.108.16.0/22", "91.108.12.0/22", "149.154.160.0/20",
+ "91.105.192.0/23", "91.108.20.0/22", "185.76.151.0/24",
+}
+
+// ProxyDNSDomains — domains NOT in refilter-domains.srs but must resolve via proxy DNS.
+// refilter-domains.srs (81k+ domains) covers all RKN-blocked domains.
+// This list only has domains missing from .srs that we still need through proxy.
+var ProxyDNSDomains = []string{
+ // Business-specific (not RKN-blocked)
+ "lovense.com", "lovense-api.com", "lovense.club",
+ // Not in refilter but needed
+ "anthropic.com",
+ "igcdn.com", "fbsbx.com",
+ // IP check services (must show proxy exit IP)
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", "ipify.org",
+}
+
+// IPCheckDomains — domains used for exit IP verification.
+var IPCheckDomains = []string{
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io",
+}
+
+// BuildBypassProcesses merges default + custom bypass processes.
+func BuildBypassProcesses(custom []string) []string {
+ seen := make(map[string]bool, len(BypassProcesses)+len(custom))
+ result := make([]string, 0, len(BypassProcesses)+len(custom))
+ for _, p := range BypassProcesses {
+ if !seen[p] {
+ seen[p] = true
+ result = append(result, p)
+ }
+ }
+ for _, p := range custom {
+ if p != "" && !seen[p] {
+ seen[p] = true
+ result = append(result, p)
+ }
+ }
+ return result
+}
+
+// BuildBypassIPs merges static bypass IPs with dynamic server IPs.
+func BuildBypassIPs(serverIPs []string) []string {
+ seen := make(map[string]bool, len(StaticBypassIPs)+len(serverIPs))
+ result := make([]string, 0, len(StaticBypassIPs)+len(serverIPs))
+
+ for _, ip := range StaticBypassIPs {
+ if !seen[ip] {
+ seen[ip] = true
+ result = append(result, ip)
+ }
+ }
+ for _, ip := range serverIPs {
+ cidr := ip + "/32"
+ if !seen[cidr] {
+ seen[cidr] = true
+ result = append(result, cidr)
+ }
+ }
+ return result
+}
diff --git a/internal/config/modes.go b/internal/config/modes.go
new file mode 100644
index 0000000..22f1d2e
--- /dev/null
+++ b/internal/config/modes.go
@@ -0,0 +1,176 @@
+package config
+
+// Mode defines a routing mode with its specific rules.
+type Mode struct {
+ Name string
+ Final string // "direct" or "proxy"
+ Rules []Rule
+}
+
+// Rule represents a single sing-box routing rule.
+type Rule struct {
+ DomainSuffix []string `json:"domain_suffix,omitempty"`
+ DomainRegex []string `json:"domain_regex,omitempty"`
+ IPCIDR []string `json:"ip_cidr,omitempty"`
+ RuleSet []string `json:"rule_set,omitempty"`
+ Network []string `json:"network,omitempty"`
+ PortRange []string `json:"port_range,omitempty"`
+ Outbound string `json:"outbound"`
+}
+
+// Discord IPs — ported 1:1 from vpn.py.
+var DiscordIPs = []string{
+ "162.159.130.234/32", "162.159.134.234/32", "162.159.133.234/32",
+ "162.159.135.234/32", "162.159.136.234/32", "162.159.137.232/32",
+ "162.159.135.232/32", "162.159.136.232/32", "162.159.138.232/32",
+ "162.159.128.233/32", "198.244.231.90/32", "162.159.129.233/32",
+ "162.159.130.233/32", "162.159.133.233/32", "162.159.134.233/32",
+ "162.159.135.233/32", "162.159.138.234/32", "162.159.137.234/32",
+ "162.159.134.232/32", "162.159.130.235/32", "162.159.129.235/32",
+ "162.159.129.232/32", "162.159.128.235/32", "162.159.130.232/32",
+ "162.159.133.232/32", "162.159.128.232/32", "34.126.226.51/32",
+ // Voice
+ "66.22.243.0/24", "64.233.165.94/32", "35.207.188.57/32",
+ "35.207.81.249/32", "35.207.171.222/32", "195.62.89.0/24",
+ "66.22.192.0/18", "66.22.196.0/24", "66.22.197.0/24",
+ "66.22.198.0/24", "66.22.199.0/24", "66.22.216.0/24",
+ "66.22.217.0/24", "66.22.237.0/24", "66.22.238.0/24",
+ "66.22.241.0/24", "66.22.242.0/24", "66.22.244.0/24",
+ "64.71.8.96/29", "34.0.240.0/24", "34.0.241.0/24",
+ "34.0.242.0/24", "34.0.243.0/24", "34.0.244.0/24",
+ "34.0.245.0/24", "34.0.246.0/24", "34.0.247.0/24",
+ "34.0.248.0/24", "34.0.249.0/24", "34.0.250.0/24",
+ "34.0.251.0/24", "12.129.184.160/29", "138.128.136.0/21",
+ "162.158.0.0/15", "172.64.0.0/13", "34.0.0.0/15",
+ "34.2.0.0/15", "35.192.0.0/12", "35.208.0.0/12",
+ "5.200.14.128/25",
+}
+
+var DiscordDomains = []string{
+ "discord.com", "discord.gg", "discordapp.com",
+ "discord.media", "discordapp.net", "discord.net",
+}
+
+var DiscordDomainRegex = []string{".*discord.*"}
+
+var TeamsDomains = []string{
+ "teams.microsoft.com", "teams.cloud.microsoft", "lync.com",
+ "skype.com", "keydelivery.mediaservices.windows.net",
+ "streaming.mediaservices.windows.net",
+}
+
+var TeamsIPs = []string{
+ "52.112.0.0/14", "52.122.0.0/15",
+}
+
+var TeamsDomainRegex = []string{
+ `.*teams\.microsoft.*`, ".*lync.*", ".*skype.*",
+}
+
+var LovenseDomains = []string{
+ "lovense-api.com", "lovense.com", "lovense.club",
+}
+
+var LovenseDomainRegex = []string{".*lovense.*"}
+
+var OBSDomains = []string{"obsproject.com"}
+var OBSDomainRegex = []string{".*obsproject.*"}
+
+var AnyDeskDomains = []string{
+ "anydesk.com", "anydesk.com.cn", "net.anydesk.com",
+}
+
+var AnyDeskDomainRegex = []string{".*anydesk.*"}
+
+// AllModes returns all available routing modes.
+func AllModes() []Mode {
+ baseDomains := append(append(append(
+ LovenseDomains, OBSDomains...), AnyDeskDomains...), IPCheckDomains...)
+ baseRegex := append(append(
+ LovenseDomainRegex, OBSDomainRegex...), AnyDeskDomainRegex...)
+
+ discordDomains := append(append([]string{}, baseDomains...), DiscordDomains...)
+ discordRegex := append(append([]string{}, baseRegex...), DiscordDomainRegex...)
+
+ teamsDomains := append(append([]string{}, discordDomains...), TeamsDomains...)
+ teamsRegex := append(append([]string{}, discordRegex...), TeamsDomainRegex...)
+
+ return []Mode{
+ {
+ Name: "Lovense + OBS + AnyDesk",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: baseDomains, DomainRegex: baseRegex, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Lovense + OBS + AnyDesk + Discord",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Lovense + OBS + AnyDesk + Discord + Teams",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: teamsDomains, DomainRegex: teamsRegex, Outbound: "proxy"},
+ {IPCIDR: append(append([]string{}, DiscordIPs...), TeamsIPs...), Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"3478:3481", "50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Discord Only",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: append(append([]string{}, DiscordDomains...), IPCheckDomains...), DomainRegex: DiscordDomainRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Full (All Traffic)",
+ Final: "proxy",
+ Rules: nil,
+ },
+ {
+ Name: "Re-filter (обход блокировок РФ)",
+ Final: "direct",
+ Rules: []Rule{
+ {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Комбо (приложения + Re-filter)",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"},
+ },
+ },
+ }
+}
+
+// ModeByName finds a mode by name, returns nil if not found.
+func ModeByName(name string) *Mode {
+ for _, m := range AllModes() {
+ if m.Name == name {
+ return &m
+ }
+ }
+ return nil
+}
+
+// ModeNames returns all available mode names.
+func ModeNames() []string {
+ modes := AllModes()
+ names := make([]string, len(modes))
+ for i, m := range modes {
+ names[i] = m.Name
+ }
+ return names
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
new file mode 100644
index 0000000..f71220f
--- /dev/null
+++ b/internal/engine/engine.go
@@ -0,0 +1,134 @@
+package engine
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+
+ box "github.com/sagernet/sing-box"
+ "github.com/sagernet/sing-box/include"
+ "github.com/sagernet/sing-box/option"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+type Engine struct {
+ mu sync.Mutex
+ instance *box.Box
+ cancel context.CancelFunc
+ running bool
+ configPath string
+ dataDir string
+}
+
+func New(dataDir string) *Engine {
+ return &Engine{
+ dataDir: dataDir,
+ configPath: filepath.Join(dataDir, "config.json"),
+ }
+}
+
+func (e *Engine) Start(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error {
+ return e.StartFull(server, mode, ruleSets, serverIPs, nil)
+}
+
+func (e *Engine) StartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ if e.running {
+ return fmt.Errorf("already running")
+ }
+
+ cfg := config.BuildConfigFull(server, mode, ruleSets, serverIPs, customBypass)
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+
+ os.MkdirAll(e.dataDir, 0o755)
+ _ = os.WriteFile(e.configPath, data, 0o644)
+ log.Printf("engine: config saved (%d bytes)", len(data))
+
+ var opts option.Options
+ ctx := box.Context(
+ context.Background(),
+ include.InboundRegistry(),
+ include.OutboundRegistry(),
+ include.EndpointRegistry(),
+ )
+ if err := opts.UnmarshalJSONContext(ctx, data); err != nil {
+ log.Printf("engine: parse FAILED: %v", err)
+ return fmt.Errorf("parse config: %w", err)
+ }
+
+ boxCtx, cancel := context.WithCancel(ctx)
+ e.cancel = cancel
+
+ instance, err := box.New(box.Options{
+ Context: boxCtx,
+ Options: opts,
+ })
+ if err != nil {
+ cancel()
+ log.Printf("engine: create FAILED: %v", err)
+ return fmt.Errorf("create sing-box: %w", err)
+ }
+
+ if err := instance.Start(); err != nil {
+ instance.Close()
+ cancel()
+ log.Printf("engine: start FAILED: %v", err)
+ return fmt.Errorf("start sing-box: %w", err)
+ }
+
+ e.instance = instance
+ e.running = true
+ log.Println("engine: started ok")
+ return nil
+}
+
+func (e *Engine) Stop() error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ if !e.running {
+ return nil
+ }
+
+ if e.instance != nil {
+ e.instance.Close()
+ e.instance = nil
+ }
+ if e.cancel != nil {
+ e.cancel()
+ }
+ e.running = false
+ log.Println("engine: stopped")
+ return nil
+}
+
+func (e *Engine) Restart(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error {
+ e.Stop()
+ return e.Start(server, mode, ruleSets, serverIPs)
+}
+
+func (e *Engine) RestartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string) error {
+ e.Stop()
+ return e.StartFull(server, mode, ruleSets, serverIPs, customBypass)
+}
+
+func (e *Engine) IsRunning() bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.running
+}
+
+func (e *Engine) ConfigPath() string {
+ return e.configPath
+}
diff --git a/internal/engine/logger.go b/internal/engine/logger.go
new file mode 100644
index 0000000..c448a56
--- /dev/null
+++ b/internal/engine/logger.go
@@ -0,0 +1,62 @@
+package engine
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// RingLog keeps last N log lines in memory and optionally writes to file.
+type RingLog struct {
+ mu sync.Mutex
+ lines []string
+ max int
+ file *os.File
+}
+
+// NewRingLog creates a ring buffer logger.
+func NewRingLog(maxLines int, dataDir string) *RingLog {
+ rl := &RingLog{
+ lines: make([]string, 0, maxLines),
+ max: maxLines,
+ }
+ if dataDir != "" {
+ f, err := os.OpenFile(filepath.Join(dataDir, "vpnem.log"),
+ os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err == nil {
+ rl.file = f
+ }
+ }
+ return rl
+}
+
+// Add appends a line.
+func (rl *RingLog) Add(line string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ if len(rl.lines) >= rl.max {
+ rl.lines = rl.lines[1:]
+ }
+ rl.lines = append(rl.lines, line)
+
+ if rl.file != nil {
+ rl.file.WriteString(line + "\n")
+ }
+}
+
+// Lines returns all current lines.
+func (rl *RingLog) Lines() []string {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ cp := make([]string, len(rl.lines))
+ copy(cp, rl.lines)
+ return cp
+}
+
+// Close closes the log file.
+func (rl *RingLog) Close() {
+ if rl.file != nil {
+ rl.file.Close()
+ }
+}
diff --git a/internal/engine/watchdog.go b/internal/engine/watchdog.go
new file mode 100644
index 0000000..899f81f
--- /dev/null
+++ b/internal/engine/watchdog.go
@@ -0,0 +1,142 @@
+package engine
+
+import (
+ "context"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+// WatchdogConfig holds watchdog parameters.
+type WatchdogConfig struct {
+ CheckInterval time.Duration // how often to check sing-box is alive (default 2s)
+ DeepCheckInterval time.Duration // how often to verify exit IP (default 30s)
+ ReconnectCooldown time.Duration // min time between reconnect attempts (default 5s)
+}
+
+// DefaultWatchdogConfig returns the default watchdog settings (from vpn.py).
+func DefaultWatchdogConfig() WatchdogConfig {
+ return WatchdogConfig{
+ CheckInterval: 2 * time.Second,
+ DeepCheckInterval: 30 * time.Second,
+ ReconnectCooldown: 5 * time.Second,
+ }
+}
+
+// Watchdog monitors sing-box and auto-reconnects on failure.
+type Watchdog struct {
+ engine *Engine
+ cfg WatchdogConfig
+ cancel context.CancelFunc
+ running bool
+
+ // Reconnect parameters (set via StartWatching)
+ server models.Server
+ mode config.Mode
+ ruleSets []models.RuleSet
+ serverIPs []string
+}
+
+// NewWatchdog creates a new watchdog for the given engine.
+func NewWatchdog(engine *Engine, cfg WatchdogConfig) *Watchdog {
+ return &Watchdog{
+ engine: engine,
+ cfg: cfg,
+ }
+}
+
+// StartWatching begins monitoring. It stores the connection params for reconnection.
+func (w *Watchdog) StartWatching(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) {
+ w.StopWatching()
+
+ w.server = server
+ w.mode = mode
+ w.ruleSets = ruleSets
+ w.serverIPs = serverIPs
+
+ ctx, cancel := context.WithCancel(context.Background())
+ w.cancel = cancel
+ w.running = true
+
+ go w.loop(ctx)
+}
+
+// StopWatching stops the watchdog.
+func (w *Watchdog) StopWatching() {
+ if w.cancel != nil {
+ w.cancel()
+ }
+ w.running = false
+}
+
+// IsWatching returns whether the watchdog is active.
+func (w *Watchdog) IsWatching() bool {
+ return w.running
+}
+
+func (w *Watchdog) loop(ctx context.Context) {
+ ticker := time.NewTicker(w.cfg.CheckInterval)
+ defer ticker.Stop()
+
+ deepTicker := time.NewTicker(w.cfg.DeepCheckInterval)
+ defer deepTicker.Stop()
+
+ lastReconnect := time.Time{}
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+
+ case <-ticker.C:
+ if !w.engine.IsRunning() {
+ if time.Since(lastReconnect) < w.cfg.ReconnectCooldown {
+ continue
+ }
+ log.Println("watchdog: sing-box not running, reconnecting...")
+ if err := w.engine.Start(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil {
+ log.Printf("watchdog: reconnect failed: %v", err)
+ } else {
+ log.Println("watchdog: reconnected successfully")
+ }
+ lastReconnect = time.Now()
+ }
+
+ case <-deepTicker.C:
+ if !w.engine.IsRunning() {
+ continue
+ }
+ ip := checkExitIP()
+ if ip == "" {
+ log.Println("watchdog: deep check failed (no exit IP), restarting...")
+ if time.Since(lastReconnect) < w.cfg.ReconnectCooldown {
+ continue
+ }
+ if err := w.engine.Restart(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil {
+ log.Printf("watchdog: restart failed: %v", err)
+ }
+ lastReconnect = time.Now()
+ }
+ }
+ }
+}
+
+func checkExitIP() string {
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Get("http://ifconfig.me/ip")
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(body))
+}
diff --git a/internal/models/ruleset.go b/internal/models/ruleset.go
new file mode 100644
index 0000000..0764fc8
--- /dev/null
+++ b/internal/models/ruleset.go
@@ -0,0 +1,22 @@
+package models
+
+type RuleSet struct {
+ Tag string `json:"tag"`
+ Description string `json:"description"`
+ URL string `json:"url"`
+ Format string `json:"format"` // binary, source
+ Type string `json:"type"` // domain, ip
+ Optional bool `json:"optional"`
+ SHA256 string `json:"sha256,omitempty"`
+}
+
+type RuleSetManifest struct {
+ RuleSets []RuleSet `json:"rule_sets"`
+}
+
+type VersionResponse struct {
+ Version string `json:"version"`
+ URL string `json:"url"`
+ SHA256 string `json:"sha256,omitempty"`
+ Changelog string `json:"changelog,omitempty"`
+}
diff --git a/internal/models/server.go b/internal/models/server.go
new file mode 100644
index 0000000..2ca2701
--- /dev/null
+++ b/internal/models/server.go
@@ -0,0 +1,29 @@
+package models
+
+type TLS struct {
+ Enabled bool `json:"enabled"`
+ ServerName string `json:"server_name,omitempty"`
+}
+
+type Transport struct {
+ Type string `json:"type,omitempty"`
+ Path string `json:"path,omitempty"`
+}
+
+type Server struct {
+ Tag string `json:"tag"`
+ Region string `json:"region"`
+ Type string `json:"type"` // socks, vless, shadowsocks
+ Server string `json:"server"`
+ ServerPort int `json:"server_port"`
+ UDPOverTCP bool `json:"udp_over_tcp,omitempty"`
+ UUID string `json:"uuid,omitempty"`
+ Method string `json:"method,omitempty"`
+ Password string `json:"password,omitempty"`
+ TLS *TLS `json:"tls,omitempty"`
+ Transport *Transport `json:"transport,omitempty"`
+}
+
+type ServersResponse struct {
+ Servers []Server `json:"servers"`
+}
diff --git a/internal/rules/loader.go b/internal/rules/loader.go
new file mode 100644
index 0000000..05ba1a0
--- /dev/null
+++ b/internal/rules/loader.go
@@ -0,0 +1,61 @@
+package rules
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+
+ "vpnem/internal/models"
+)
+
+type Store struct {
+ dataDir string
+}
+
+func NewStore(dataDir string) *Store {
+ return &Store{dataDir: dataDir}
+}
+
+func (s *Store) LoadServers() (*models.ServersResponse, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "servers.json"))
+ if err != nil {
+ return nil, err
+ }
+ var resp models.ServersResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func (s *Store) LoadRuleSets() (*models.RuleSetManifest, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "rulesets.json"))
+ if err != nil {
+ return nil, err
+ }
+ var manifest models.RuleSetManifest
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return nil, err
+ }
+ return &manifest, nil
+}
+
+func (s *Store) LoadVersion() (*models.VersionResponse, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "version.json"))
+ if err != nil {
+ return nil, err
+ }
+ var ver models.VersionResponse
+ if err := json.Unmarshal(data, &ver); err != nil {
+ return nil, err
+ }
+ return &ver, nil
+}
+
+func (s *Store) RulesDir() string {
+ return filepath.Join(s.dataDir, "rules")
+}
+
+func (s *Store) ReleasesDir() string {
+ return filepath.Join(s.dataDir, "releases")
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..83f77b1
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,137 @@
+package state
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+// AppState holds persistent client state.
+type AppState struct {
+ SelectedServer string `json:"selected_server"`
+ SelectedMode string `json:"selected_mode"`
+ LastSync time.Time `json:"last_sync"`
+ AutoConnect bool `json:"auto_connect"`
+ EnabledRuleSets map[string]bool `json:"enabled_rule_sets,omitempty"`
+ CustomBypass []string `json:"custom_bypass_processes,omitempty"`
+}
+
+// Store manages persistent state on disk.
+type Store struct {
+ mu sync.Mutex
+ path string
+ data AppState
+}
+
+// NewStore creates a state store at the given path.
+func NewStore(dataDir string) *Store {
+ return &Store{
+ path: filepath.Join(dataDir, "state.json"),
+ data: AppState{
+ SelectedMode: "Комбо (приложения + Re-filter)",
+ AutoConnect: false,
+ },
+ }
+}
+
+// Load reads state from disk. Returns default state if file doesn't exist.
+func (s *Store) Load() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ data, err := os.ReadFile(s.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ return json.Unmarshal(data, &s.data)
+}
+
+// Save writes state to disk.
+func (s *Store) Save() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(s.data, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(s.path, data, 0o644)
+}
+
+// Get returns a copy of the current state.
+func (s *Store) Get() AppState {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.data
+}
+
+// SetServer updates the selected server.
+func (s *Store) SetServer(tag string) {
+ s.mu.Lock()
+ s.data.SelectedServer = tag
+ s.mu.Unlock()
+}
+
+// SetMode updates the selected routing mode.
+func (s *Store) SetMode(mode string) {
+ s.mu.Lock()
+ s.data.SelectedMode = mode
+ s.mu.Unlock()
+}
+
+// SetLastSync records the last sync time.
+func (s *Store) SetLastSync(t time.Time) {
+ s.mu.Lock()
+ s.data.LastSync = t
+ s.mu.Unlock()
+}
+
+// SetAutoConnect updates the auto-connect setting.
+func (s *Store) SetAutoConnect(v bool) {
+ s.mu.Lock()
+ s.data.AutoConnect = v
+ s.mu.Unlock()
+}
+
+// SetRuleSetEnabled enables/disables an optional rule-set.
+func (s *Store) SetRuleSetEnabled(tag string, enabled bool) {
+ s.mu.Lock()
+ if s.data.EnabledRuleSets == nil {
+ s.data.EnabledRuleSets = make(map[string]bool)
+ }
+ s.data.EnabledRuleSets[tag] = enabled
+ s.mu.Unlock()
+}
+
+// IsRuleSetEnabled checks if a rule-set is enabled.
+func (s *Store) IsRuleSetEnabled(tag string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.data.EnabledRuleSets == nil {
+ return false
+ }
+ return s.data.EnabledRuleSets[tag]
+}
+
+// SetCustomBypass sets custom bypass processes.
+func (s *Store) SetCustomBypass(processes []string) {
+ s.mu.Lock()
+ s.data.CustomBypass = processes
+ s.mu.Unlock()
+}
+
+// GetCustomBypass returns custom bypass processes.
+func (s *Store) GetCustomBypass() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]string{}, s.data.CustomBypass...)
+}
diff --git a/internal/sync/fetcher.go b/internal/sync/fetcher.go
new file mode 100644
index 0000000..74c2bd6
--- /dev/null
+++ b/internal/sync/fetcher.go
@@ -0,0 +1,82 @@
+package sync
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+// Fetcher pulls configuration from the vpnem server API.
+type Fetcher struct {
+ baseURL string
+ client *http.Client
+}
+
+// NewFetcher creates a new Fetcher.
+func NewFetcher(baseURL string) *Fetcher {
+ return &Fetcher{
+ baseURL: baseURL,
+ client: &http.Client{
+ Timeout: 15 * time.Second,
+ },
+ }
+}
+
+// FetchServers retrieves the server list from the API.
+func (f *Fetcher) FetchServers() (*models.ServersResponse, error) {
+ var resp models.ServersResponse
+ if err := f.getJSON("/api/v1/servers", &resp); err != nil {
+ return nil, fmt.Errorf("fetch servers: %w", err)
+ }
+ return &resp, nil
+}
+
+// FetchRuleSets retrieves the rule-set manifest from the API.
+func (f *Fetcher) FetchRuleSets() (*models.RuleSetManifest, error) {
+ var resp models.RuleSetManifest
+ if err := f.getJSON("/api/v1/ruleset/manifest", &resp); err != nil {
+ return nil, fmt.Errorf("fetch rulesets: %w", err)
+ }
+ return &resp, nil
+}
+
+// FetchVersion retrieves the latest client version info.
+func (f *Fetcher) FetchVersion() (*models.VersionResponse, error) {
+ var resp models.VersionResponse
+ if err := f.getJSON("/api/v1/version", &resp); err != nil {
+ return nil, fmt.Errorf("fetch version: %w", err)
+ }
+ return &resp, nil
+}
+
+// ServerIPs extracts all unique server IPs from the server list.
+func ServerIPs(servers []models.Server) []string {
+ seen := make(map[string]bool)
+ var ips []string
+ for _, s := range servers {
+ if !seen[s.Server] {
+ seen[s.Server] = true
+ ips = append(ips, s.Server)
+ }
+ }
+ return ips
+}
+
+func (f *Fetcher) getJSON(path string, v any) error {
+ resp, err := f.client.Get(f.baseURL + path)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ return json.NewDecoder(resp.Body).Decode(v)
+}
diff --git a/internal/sync/health.go b/internal/sync/health.go
new file mode 100644
index 0000000..4d6ceca
--- /dev/null
+++ b/internal/sync/health.go
@@ -0,0 +1,33 @@
+package sync
+
+import (
+ "fmt"
+ "net"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+// HealthCheck tests if a server's proxy port is reachable.
+func HealthCheck(server models.Server, timeout time.Duration) error {
+ addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort)
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return fmt.Errorf("server %s unreachable: %w", server.Tag, err)
+ }
+ conn.Close()
+ return nil
+}
+
+// FindHealthyServer returns the first healthy non-RU server from the list.
+func FindHealthyServer(servers []models.Server, timeout time.Duration) *models.Server {
+ for _, s := range servers {
+ if s.Region == "RU" {
+ continue
+ }
+ if err := HealthCheck(s, timeout); err == nil {
+ return &s
+ }
+ }
+ return nil
+}
diff --git a/internal/sync/latency.go b/internal/sync/latency.go
new file mode 100644
index 0000000..dd3268b
--- /dev/null
+++ b/internal/sync/latency.go
@@ -0,0 +1,62 @@
+package sync
+
+import (
+ "fmt"
+ "net"
+ "sort"
+ "sync"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+// LatencyResult holds a server's latency measurement.
+type LatencyResult struct {
+ Tag string `json:"tag"`
+ Region string `json:"region"`
+ Latency int `json:"latency_ms"` // -1 means unreachable
+}
+
+// MeasureLatency pings all servers concurrently and returns results sorted by latency.
+func MeasureLatency(servers []models.Server, timeout time.Duration) []LatencyResult {
+ var wg sync.WaitGroup
+ results := make([]LatencyResult, len(servers))
+
+ for i, s := range servers {
+ wg.Add(1)
+ go func(idx int, srv models.Server) {
+ defer wg.Done()
+ ms := tcpPing(srv.Server, srv.ServerPort, timeout)
+ results[idx] = LatencyResult{
+ Tag: srv.Tag,
+ Region: srv.Region,
+ Latency: ms,
+ }
+ }(i, s)
+ }
+
+ wg.Wait()
+
+ sort.Slice(results, func(i, j int) bool {
+ if results[i].Latency == -1 {
+ return false
+ }
+ if results[j].Latency == -1 {
+ return true
+ }
+ return results[i].Latency < results[j].Latency
+ })
+
+ return results
+}
+
+func tcpPing(host string, port int, timeout time.Duration) int {
+ addr := fmt.Sprintf("%s:%d", host, port)
+ start := time.Now()
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return -1
+ }
+ conn.Close()
+ return int(time.Since(start).Milliseconds())
+}
diff --git a/internal/sync/updater.go b/internal/sync/updater.go
new file mode 100644
index 0000000..23cbd19
--- /dev/null
+++ b/internal/sync/updater.go
@@ -0,0 +1,159 @@
+package sync
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "time"
+)
+
+// Updater checks for and downloads client updates.
+type Updater struct {
+ fetcher *Fetcher
+ currentVer string
+ dataDir string
+}
+
+// NewUpdater creates an updater.
+func NewUpdater(fetcher *Fetcher, currentVersion, dataDir string) *Updater {
+ return &Updater{
+ fetcher: fetcher,
+ currentVer: currentVersion,
+ dataDir: dataDir,
+ }
+}
+
+// UpdateInfo describes an available update.
+type UpdateInfo struct {
+ Available bool `json:"available"`
+ Version string `json:"version"`
+ Changelog string `json:"changelog"`
+ CurrentVer string `json:"current_version"`
+}
+
+// Check returns info about available updates.
+func (u *Updater) Check() (*UpdateInfo, error) {
+ ver, err := u.fetcher.FetchVersion()
+ if err != nil {
+ return nil, fmt.Errorf("check update: %w", err)
+ }
+
+ return &UpdateInfo{
+ Available: ver.Version != u.currentVer,
+ Version: ver.Version,
+ Changelog: ver.Changelog,
+ CurrentVer: u.currentVer,
+ }, nil
+}
+
+// Download fetches the new binary, cleans stale configs, replaces current binary and restarts.
+func (u *Updater) Download() (string, error) {
+ ver, err := u.fetcher.FetchVersion()
+ if err != nil {
+ return "", fmt.Errorf("fetch version: %w", err)
+ }
+
+ if ver.URL == "" {
+ suffix := "linux-amd64"
+ if runtime.GOOS == "windows" {
+ suffix = "windows-amd64.exe"
+ }
+ ver.URL = u.fetcher.baseURL + "/releases/vpnem-" + suffix
+ }
+
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Get(ver.URL)
+ if err != nil {
+ return "", fmt.Errorf("download: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download: HTTP %d", resp.StatusCode)
+ }
+
+ ext := ""
+ if runtime.GOOS == "windows" {
+ ext = ".exe"
+ }
+
+ newBin := filepath.Join(u.dataDir, "vpnem-update"+ext)
+ f, err := os.Create(newBin)
+ if err != nil {
+ return "", fmt.Errorf("create file: %w", err)
+ }
+
+ if _, err := io.Copy(f, resp.Body); err != nil {
+ f.Close()
+ os.Remove(newBin)
+ return "", fmt.Errorf("write update: %w", err)
+ }
+ f.Close()
+
+ if runtime.GOOS != "windows" {
+ os.Chmod(newBin, 0o755)
+ }
+
+ // Clean stale configs so new version starts fresh
+ os.Remove(filepath.Join(u.dataDir, "state.json"))
+ os.Remove(filepath.Join(u.dataDir, "config.json"))
+ os.Remove(filepath.Join(u.dataDir, "cache.db"))
+
+ // Replace current binary and restart
+ currentBin, _ := os.Executable()
+ if currentBin != "" {
+ if runtime.GOOS == "windows" {
+ // Windows can't overwrite running exe — rename old, copy new, restart
+ oldBin := currentBin + ".old"
+ os.Remove(oldBin)
+ os.Rename(currentBin, oldBin)
+ copyFile(newBin, currentBin)
+ // Restart: launch new binary and exit current
+ cmd := exec.Command(currentBin)
+ cmd.Dir = u.dataDir
+ cmd.Start()
+ os.Exit(0)
+ } else {
+ // Linux: overwrite in place, then re-exec
+ copyFile(newBin, currentBin)
+ os.Remove(newBin)
+ cmd := exec.Command(currentBin)
+ cmd.Dir = u.dataDir
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Start()
+ os.Exit(0)
+ }
+ }
+
+ return newBin, nil
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ if err != nil {
+ return err
+ }
+
+ // Preserve executable permission on Linux
+ if runtime.GOOS != "windows" {
+ os.Chmod(dst, 0o755)
+ }
+ return nil
+}
diff --git a/scripts/update-rulesets.sh b/scripts/update-rulesets.sh
new file mode 100755
index 0000000..24dcfcb
--- /dev/null
+++ b/scripts/update-rulesets.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Downloads public .srs rule-sets from GitHub
+# Run via cron: 0 4 * * 1 /opt/vpnem/scripts/update-rulesets.sh
+
+set -euo pipefail
+
+RULES_DIR="${1:-/opt/vpnem/data/rules}"
+mkdir -p "$RULES_DIR"
+
+download() {
+ local url="$1"
+ local dest="$2"
+ local tmp="${dest}.tmp"
+
+ if wget -q -O "$tmp" "$url"; then
+ mv "$tmp" "$dest"
+ echo "OK: $(basename "$dest")"
+ else
+ rm -f "$tmp"
+ echo "FAIL: $(basename "$dest")"
+ return 1
+ fi
+}
+
+download \
+ "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-domain-refilter_domains.srs" \
+ "$RULES_DIR/refilter-domains.srs"
+
+download \
+ "https://github.com/1andrevich/Re-filter-lists/releases/latest/download/ruleset-ip-refilter_ipsum.srs" \
+ "$RULES_DIR/refilter-ip.srs"
+
+download \
+ "https://github.com/legiz-ru/sb-rule-sets/raw/main/discord-voice-ip-list.srs" \
+ "$RULES_DIR/discord-voice-ip-list.srs"
+
+echo "Rule-set update complete"