summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
committerSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
commit1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch)
treed8c85273ede547e03a5727bf185f5d07e87b4a08 /internal
downloadvpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.gz
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.bz2
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.zip
Initial importHEADmain
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers.go54
-rw-r--r--internal/api/handlers_test.go129
-rw-r--r--internal/api/router.go30
-rw-r--r--internal/config/builder.go182
-rw-r--r--internal/config/builder_test.go232
-rw-r--r--internal/config/bypass.go139
-rw-r--r--internal/config/modes.go176
-rw-r--r--internal/engine/engine.go134
-rw-r--r--internal/engine/logger.go62
-rw-r--r--internal/engine/watchdog.go142
-rw-r--r--internal/models/ruleset.go22
-rw-r--r--internal/models/server.go29
-rw-r--r--internal/rules/loader.go61
-rw-r--r--internal/state/state.go137
-rw-r--r--internal/sync/fetcher.go82
-rw-r--r--internal/sync/health.go33
-rw-r--r--internal/sync/latency.go62
-rw-r--r--internal/sync/updater.go159
18 files changed, 1865 insertions, 0 deletions
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
+}