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