diff options
| author | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
|---|---|---|
| committer | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
| commit | 3d51aa455006903345f554a2dd90034993796114 (patch) | |
| tree | 62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/config | |
| download | vpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2 vpnem-3d51aa455006903345f554a2dd90034993796114.zip | |
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5)
- Smart load balancing via recommendation API
- Windows/Linux client (Go + Wails + sing-box)
- Server API with RealIP detection and connection tracking
- Auto-deployment via vpnui control plane
- Silent Windows installer with UAC elevation
- Load-based server recommendation (no sticky sessions)
- Best Server one-click connection workflow
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/builder.go | 340 | ||||
| -rw-r--r-- | internal/config/builder_test.go | 431 | ||||
| -rw-r--r-- | internal/config/bypass.go | 169 | ||||
| -rw-r--r-- | internal/config/modes.go | 176 | ||||
| -rw-r--r-- | internal/config/outbounds.go | 212 | ||||
| -rw-r--r-- | internal/config/policy.go | 102 |
6 files changed, 1430 insertions, 0 deletions
diff --git a/internal/config/builder.go b/internal/config/builder.go new file mode 100644 index 0000000..96ccdbc --- /dev/null +++ b/internal/config/builder.go @@ -0,0 +1,340 @@ +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"` +} + +const ( + LocalProxyHost = "127.0.0.1" + LocalProxyPort = 10800 + TunInterfaceName = "vpnem" +) + +func BuildConfig(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string) SingBoxConfig { + return BuildConfigFull(server, mode, ruleSets, serverIPs, nil, nil) +} + +// BuildConfigFull — exact vpn.py config. Fast, proven. +func BuildConfigFull(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, policy *models.RoutingPolicy) SingBoxConfig { + return BuildConfigFullWithLocalProxy(server, mode, ruleSets, serverIPs, customBypass, LocalProxyPort, policy) +} + +func BuildConfigFullWithLocalProxy(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig { + if hy2, ok := findCompanionProtocol(server, "hysteria2"); ok && (server.Type == "vless-reality" || server.Type == "vless") { + return BuildSplitRoutingConfig(server, hy2, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy) + } + + effectivePolicy := EffectiveRoutingPolicy(policy) + bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs) + bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass) + + var rules []map[string]any + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"}) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"}) + 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": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs, "outbound": "proxy"}) + rules = append(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses, "outbound": "proxy"}) + rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex, "outbound": "proxy"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs, "outbound": "proxy"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.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) + } + + if len(effectivePolicy.PreferDirectProcesses) > 0 { + rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"}) + } + + var ruleSetDefs []map[string]any + for _, rs := range ruleSets { + if rs.URL == "" { + continue + } + ruleSetDefs = append(ruleSetDefs, map[string]any{ + "tag": rs.Tag, "type": "local", "format": rs.Format, + "path": rs.LocalPath, + }) + } + + 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", "type": "https", "server": "8.8.8.8", "detour": "proxy"}, + {"tag": "direct-dns", "type": "https", "server": "1.1.1.1"}, + }, + "rules": []map[string]any{ + {"outbound": "proxy", "server": "proxy-dns"}, + {"outbound": "direct", "server": "direct-dns"}, + }, + "strategy": "ipv4_only", + }, + Inbounds: []map[string]any{ + { + "type": "tun", + "tag": "tun-in", + "interface_name": TunInterfaceName, + "address": []string{"172.19.0.1/30"}, + "auto_route": true, + "strict_route": false, + "stack": "gvisor", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": LocalProxyHost, + "listen_port": defaultInt(localProxyPort, LocalProxyPort), + }, + }, + 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 BuildSplitRoutingConfig(vlessServer models.Server, hy2Server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig { + effectivePolicy := EffectiveRoutingPolicy(policy) + bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs) + bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass) + + var rules []map[string]any + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"}) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"}) + rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"}) + 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": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"}) + rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"}) + rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs}) + rules = appendSplitProxyRule(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses}) + rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex}) + rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs}) + rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.BlockedDomains}) + + for _, r := range mode.Rules { + rule := map[string]any{} + 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 + } + if r.Outbound == "proxy" { + rules = appendSplitProxyRule(rules, rule) + } else { + rule["outbound"] = r.Outbound + rules = append(rules, rule) + } + } + + if len(effectivePolicy.PreferDirectProcesses) > 0 { + rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"}) + } + + if mode.Final == "proxy" { + rules = append(rules, + map[string]any{"network": []string{"udp"}, "outbound": "hysteria2-out"}, + map[string]any{"network": []string{"tcp"}, "outbound": "vless-out"}, + ) + } + + 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": splitFinalOutbound(mode.Final), + "rules": rules, + "default_domain_resolver": map[string]any{ + "server": "direct-dns", + "strategy": "ipv4_only", + }, + } + if len(ruleSetDefs) > 0 { + route["rule_set"] = ruleSetDefs + } + + return SingBoxConfig{ + DNS: map[string]any{ + "servers": []map[string]any{ + {"tag": "proxy-dns", "type": "https", "server": "8.8.8.8", "detour": "vless-out"}, + {"tag": "direct-dns", "type": "udp", "server": "1.1.1.1", "server_port": 53}, + }, + "rules": []map[string]any{ + {"outbound": "vless-out", "server": "proxy-dns"}, + {"outbound": "hysteria2-out", "server": "proxy-dns"}, + {"outbound": "direct", "server": "direct-dns"}, + }, + "strategy": "ipv4_only", + }, + Inbounds: []map[string]any{ + { + "type": "tun", + "tag": "tun-in", + "interface_name": TunInterfaceName, + "address": []string{"172.19.0.1/30"}, + "auto_route": true, + "strict_route": false, + "stack": "gvisor", + }, + { + "type": "socks", + "tag": "socks-in", + "listen": LocalProxyHost, + "listen_port": defaultInt(localProxyPort, LocalProxyPort), + }, + }, + Outbounds: []map[string]any{ + BuildOutboundWithTag(vlessServer, "vless-out"), + BuildOutboundWithTag(hy2Server, "hysteria2-out"), + {"type": "direct", "tag": "direct"}, + }, + Route: route, + Experimental: map[string]any{ + "cache_file": map[string]any{ + "enabled": true, + "path": "cache.db", + }, + }, + } +} + +func findCompanionProtocol(server models.Server, protocolType string) (models.Server, bool) { + for _, companion := range server.Companions { + if companion.Type == protocolType { + return companion, true + } + } + return models.Server{}, false +} + +func splitFinalOutbound(final string) string { + if final == "proxy" { + return "vless-out" + } + return final +} + +func appendSplitProxyRule(rules []map[string]any, base map[string]any) []map[string]any { + if rule, ok := splitRuleForNetwork(base, "tcp", "vless-out"); ok { + rules = append(rules, rule) + } + if rule, ok := splitRuleForNetwork(base, "udp", "hysteria2-out"); ok { + rules = append(rules, rule) + } + return rules +} + +func splitRuleForNetwork(base map[string]any, network string, outbound string) (map[string]any, bool) { + rule := copyRule(base) + if networks, ok := rule["network"].([]string); ok && len(networks) > 0 { + if !containsString(networks, network) { + return nil, false + } + rule["network"] = []string{network} + } else { + rule["network"] = []string{network} + } + rule["outbound"] = outbound + return rule, true +} + +func copyRule(in map[string]any) map[string]any { + out := make(map[string]any, len(in)+1) + for k, v := range in { + out[k] = v + } + return out +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func defaultInt(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} diff --git a/internal/config/builder_test.go b/internal/config/builder_test.go new file mode 100644 index 0000000..0d46659 --- /dev/null +++ b/internal/config/builder_test.go @@ -0,0 +1,431 @@ +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") + } + if !strings.Contains(s, `"type":"socks"`) { + t.Error("expected local socks inbound") + } + if !strings.Contains(s, `"listen":"127.0.0.1"`) { + t.Error("expected local socks proxy listen host") + } + if !strings.Contains(s, `"listen_port":10800`) && !strings.Contains(s, `"listen_port": 10800`) { + t.Error("expected local socks proxy on port 10800") + } + // Verify bypass processes present + if !strings.Contains(s, "chromium.exe") { + t.Error("expected chromium.exe in direct bypass list") + } + if !strings.Contains(s, "Performer Application v5.x.exe") { + t.Error("expected Performer Application v5.x.exe in direct bypass list") + } + if !strings.Contains(s, "Яндекс Музыка.exe") { + t.Error("expected Яндекс Музыка.exe in direct bypass list") + } + if strings.Contains(s, "chrome.exe") { + t.Error("did not expect chrome.exe in direct bypass list") + } + if strings.Contains(s, "firefox.exe") { + t.Error("did not expect firefox.exe in direct bypass list") + } + if strings.Contains(s, "msedgewebview2.exe") { + t.Error("did not expect msedgewebview2.exe in direct bypass list") + } + if !strings.Contains(s, "obs64.exe") { + t.Error("expected obs64.exe in config rules") + } + // 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") + } + if !strings.Contains(s, "Telegram.exe") { + t.Error("expected Telegram.exe process rule") + } + // 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, "vpnem") { + t.Error("expected TUN interface name vpnem") + } + // Verify DNS + if !strings.Contains(s, "proxy-dns") { + t.Error("expected proxy-dns server") + } + // Verify cache_file + if !strings.Contains(s, "cache_file") { + t.Error("expected cache_file in experimental") + } + // sing-box 1.12: sniff/hijack-dns are route actions, not inbound flags. + if strings.Contains(s, `"sniff":true`) { + t.Error("did not expect legacy inbound sniff flags in 1.12 config") + } + if strings.Contains(s, `"sniff_override_destination":true`) { + t.Error("did not expect legacy sniff_override_destination in 1.12 config") + } + if !strings.Contains(s, `"action":"sniff"`) { + t.Error("expected route sniff action in 1.12 config") + } + if !strings.Contains(s, `"action":"hijack-dns"`) { + t.Error("expected route hijack-dns action in 1.12 config") + } + // sing-box 1.12: DoH servers use type+server, not address URLs. + if strings.Contains(s, `dns-query`) { + t.Error("did not expect legacy dns-query URLs in 1.12 config") + } + if !strings.Contains(s, `"type":"https"`) { + t.Error("expected https DNS server type") + } + if !strings.Contains(s, `"server":"1.1.1.1"`) { + t.Error("expected 1.1.1.1 DoH server") + } + if !strings.Contains(s, "default_domain_resolver") { + t.Error("expected default_domain_resolver in route") + } +} + +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 TestBuildConfigVLESSReality(t *testing.T) { + server := models.Server{ + Tag: "nl-reality", + Region: "NL", + Type: "vless-reality", + Server: "203.0.113.20", + ServerPort: 443, + UUID: "33333333-3333-3333-3333-333333333333", + TLS: &models.TLS{ + Enabled: true, + ServerName: "login.microsoftonline.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + } + 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 for reality") + } + if !strings.Contains(s, `"public_key":"jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0"`) { + t.Error("expected reality public key") + } + if !strings.Contains(s, `"short_id":"0123456789abcdef"`) { + t.Error("expected reality short id") + } + if !strings.Contains(s, `"fingerprint":"chrome"`) { + t.Error("expected reality utls fingerprint") + } +} + +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 TestBuildConfigVMess(t *testing.T) { + server := models.Server{ + Tag: "nl-vmess", Region: "NL", Type: "vmess", + Server: "nl.example.com", ServerPort: 8444, UUID: "22222222-2222-2222-2222-222222222222", + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/vmess"}, + } + 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":"vmess"`) { + t.Error("expected vmess outbound") + } + if !strings.Contains(s, "22222222-2222-2222-2222-222222222222") { + t.Error("expected vmess uuid") + } + if !strings.Contains(s, `"/vmess"`) { + t.Error("expected vmess ws path") + } +} + +func TestBuildConfigHysteria2(t *testing.T) { + server := models.Server{ + Tag: "nl-hy2", Region: "NL", Type: "hysteria2", + Server: "nl.example.com", ServerPort: 9443, Password: "hy2-secret", ObfsPassword: "obfs-secret", + UpMbps: 80, DownMbps: 90, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"}, + } + 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":"hysteria2"`) { + t.Error("expected hysteria2 outbound") + } + if !strings.Contains(s, `"password":"hy2-secret"`) { + t.Error("expected hysteria2 password") + } + if !strings.Contains(s, `"salamander"`) { + t.Error("expected hysteria2 obfs configuration") + } + if !strings.Contains(s, `"up_mbps":80`) && !strings.Contains(s, `"up_mbps": 80`) { + t.Error("expected hysteria2 up_mbps") + } + if !strings.Contains(s, `"insecure":true`) && !strings.Contains(s, `"insecure": true`) { + t.Error("expected hysteria2 tls.insecure") + } + if !strings.Contains(s, `"alpn":["h3"]`) && !strings.Contains(s, `"alpn": ["h3"]`) { + t.Error("expected hysteria2 tls alpn h3") + } + if !strings.Contains(s, `"min_version":"1.3"`) && !strings.Contains(s, `"min_version": "1.3"`) { + t.Error("expected hysteria2 tls min_version") + } +} + +func TestBuildConfigSplitRealityHysteria2(t *testing.T) { + server := models.Server{ + Tag: "nl-multi", + Region: "NL", + Type: "vless-reality", + Server: "203.0.113.50", + ServerPort: 443, + UUID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + TLS: &models.TLS{ + Enabled: true, + ServerName: "www.microsoft.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "pubkey", + ShortID: "abcdef1234567890", + Fingerprint: "chrome", + }, + }, + Companions: []models.Server{ + { + Tag: "nl-multi-hysteria2", + Region: "NL", + Type: "hysteria2", + Server: "203.0.113.50", + ServerPort: 443, + Password: "hy2-secret", + ObfsPassword: "obfs-secret", + UpMbps: 100, + DownMbps: 100, + TLS: &models.TLS{ + Enabled: true, + Insecure: true, + ALPN: []string{"h3"}, + MinVersion: "1.3", + MaxVersion: "1.3", + }, + }, + }, + } + mode := *config.ModeByName("Full (All Traffic)") + + cfg := config.BuildConfig(server, mode, nil, nil) + data, _ := json.Marshal(cfg) + s := string(data) + + if !strings.Contains(s, `"tag":"vless-out"`) { + t.Fatal("expected vless-out outbound tag") + } + if !strings.Contains(s, `"tag":"hysteria2-out"`) { + t.Fatal("expected hysteria2-out outbound tag") + } + if !strings.Contains(s, `"network":["tcp"]`) || !strings.Contains(s, `"outbound":"vless-out"`) { + t.Fatal("expected tcp split routing rule") + } + if !strings.Contains(s, `"network":["udp"]`) || !strings.Contains(s, `"outbound":"hysteria2-out"`) { + t.Fatal("expected udp split routing rule") + } + if !strings.Contains(s, `"detour":"vless-out"`) { + t.Fatal("expected proxy DNS detour via vless-out") + } +} + +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(nil, []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 TestBuildBypassIPsIgnoresHostnames(t *testing.T) { + ips := config.BuildBypassIPs(nil, []string{"xui5.em-sysadmin.xyz", "1.2.3.4"}) + + for _, ip := range ips { + if ip == "xui5.em-sysadmin.xyz/32" { + t.Fatal("expected hostname to be ignored in bypass IP list") + } + } +} + +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..9deadb7 --- /dev/null +++ b/internal/config/bypass.go @@ -0,0 +1,169 @@ +package config + +import ( + "net/netip" + + "vpnem/internal/models" +) + +// 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", + "Performer Application v5.x.exe", + "chromium.exe", + "Яндекс Музыка.exe", +} + +// PreferDirectProcesses should stay outside global bypass, but still avoid the proxy +// unless a stronger blocked/refilter/forced rule matches first. +var PreferDirectProcesses = []string{ + "obs64.exe", +} + +// ProxyableBrowserProcesses intentionally stay OUT of the default direct bypass list. +// Their traffic should follow routing mode rules, otherwise Full/Re-filter modes +// cannot proxy IP-check and blocked domains correctly. +var ProxyableBrowserProcesses = []string{ + "chrome.exe", + "firefox.exe", + "msedgewebview2.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", +} + +var TelegramProcesses = []string{ + "Telegram.exe", +} + +var TelegramProcessRegex = []string{ + `(?i).*telegram.*\\telegram\.exe$`, +} + +// 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(policy *models.RoutingPolicy, custom []string) []string { + effective := EffectiveRoutingPolicy(policy) + seen := make(map[string]bool, len(effective.AlwaysDirectProcesses)+len(custom)) + result := make([]string, 0, len(effective.AlwaysDirectProcesses)+len(custom)) + for _, p := range effective.AlwaysDirectProcesses { + 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(policy *models.RoutingPolicy, serverIPs []string) []string { + effective := EffectiveRoutingPolicy(policy) + seen := make(map[string]bool, len(effective.StaticBypassIPs)+len(serverIPs)) + result := make([]string, 0, len(effective.StaticBypassIPs)+len(serverIPs)) + + for _, ip := range effective.StaticBypassIPs { + if !seen[ip] { + seen[ip] = true + result = append(result, ip) + } + } + for _, ip := range serverIPs { + if _, err := netip.ParseAddr(ip); err != nil { + continue + } + 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/config/outbounds.go b/internal/config/outbounds.go new file mode 100644 index 0000000..582b625 --- /dev/null +++ b/internal/config/outbounds.go @@ -0,0 +1,212 @@ +package config + +import "vpnem/internal/models" + +type InboundConfig map[string]any + +func BuildOutbound(server models.Server) map[string]any { + return BuildOutboundWithTag(server, "proxy") +} + +func BuildOutboundWithTag(server models.Server, tag string) map[string]any { + switch server.Type { + case "vless": + return buildVLESSOutbound(server, tag) + case "vless-reality": + return buildVLESSRealityOutbound(server, tag) + case "vmess": + return buildVMessOutbound(server, tag) + case "shadowsocks": + return buildShadowsocksOutbound(server, tag) + case "hysteria2": + return buildHysteria2Outbound(server, tag) + default: + return buildSOCKSOutbound(server, tag) + } +} + +func BuildHysteria2Inbound(_ any, port int, password string, obfsPassword string, upMbps int, downMbps int, certPath string, keyPath string) (*InboundConfig, error) { + if password == "" { + return nil, errConfig("hysteria2 inbound requires password") + } + if certPath == "" || keyPath == "" { + return nil, errConfig("hysteria2 inbound requires certificate and key paths") + } + inbound := InboundConfig{ + "type": "hysteria2", + "tag": "hysteria2-in", + "listen": "::", + "listen_port": port, + "users": []map[string]any{ + {"name": "user-01", "password": password}, + }, + "tls": map[string]any{ + "enabled": true, + "alpn": []string{"h3"}, + "min_version": "1.3", + "max_version": "1.3", + "certificate_path": certPath, + "key_path": keyPath, + }, + } + if upMbps > 0 { + inbound["up_mbps"] = upMbps + } + if downMbps > 0 { + inbound["down_mbps"] = downMbps + } + if obfsPassword != "" { + inbound["obfs"] = map[string]any{ + "type": "salamander", + "password": obfsPassword, + } + } + return &inbound, nil +} + +func buildVLESSOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vless", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID, + } + applyTLS(outbound, server.TLS) + applyTransport(outbound, server.Transport) + return outbound +} + +func buildVLESSRealityOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vless", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID, + } + applyTLS(outbound, server.TLS) + return outbound +} + +func buildVMessOutbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "vmess", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "uuid": server.UUID, "security": "auto", "alter_id": 0, + } + applyTLS(outbound, server.TLS) + applyTransport(outbound, server.Transport) + return outbound +} + +func buildShadowsocksOutbound(server models.Server, tag string) map[string]any { + return map[string]any{ + "type": "shadowsocks", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "method": server.Method, "password": server.Password, + } +} + +func buildHysteria2Outbound(server models.Server, tag string) map[string]any { + outbound := map[string]any{ + "type": "hysteria2", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "password": server.Password, + } + if server.UpMbps > 0 { + outbound["up_mbps"] = server.UpMbps + } + if server.DownMbps > 0 { + outbound["down_mbps"] = server.DownMbps + } + if server.ObfsPassword != "" { + outbound["obfs"] = map[string]any{"type": "salamander", "password": server.ObfsPassword} + } + tlsConfig := map[string]any{ + "enabled": true, + "insecure": true, + "alpn": []string{"h3"}, + "min_version": "1.3", + "max_version": "1.3", + } + if server.TLS != nil { + if server.TLS.ServerName != "" { + tlsConfig["server_name"] = server.TLS.ServerName + } + if len(server.TLS.ALPN) > 0 { + tlsConfig["alpn"] = server.TLS.ALPN + } + if server.TLS.MinVersion != "" { + tlsConfig["min_version"] = server.TLS.MinVersion + } + if server.TLS.MaxVersion != "" { + tlsConfig["max_version"] = server.TLS.MaxVersion + } + if server.TLS.Insecure { + tlsConfig["insecure"] = true + } + } + outbound["tls"] = tlsConfig + return outbound +} + +func buildSOCKSOutbound(server models.Server, tag string) map[string]any { + return map[string]any{ + "type": "socks", "tag": tag, + "server": server.Server, "server_port": server.ServerPort, + "udp_over_tcp": server.UDPOverTCP, + } +} + +func applyTLS(outbound map[string]any, tls *models.TLS) { + if tls == nil { + return + } + tlsConfig := map[string]any{ + "enabled": tls.Enabled, + "server_name": tls.ServerName, + } + if tls.Insecure { + tlsConfig["insecure"] = true + } + if len(tls.ALPN) > 0 { + tlsConfig["alpn"] = tls.ALPN + } + if tls.MinVersion != "" { + tlsConfig["min_version"] = tls.MinVersion + } + if tls.MaxVersion != "" { + tlsConfig["max_version"] = tls.MaxVersion + } + if tls.Reality != nil && tls.Reality.Enabled { + tlsConfig["reality"] = map[string]any{ + "enabled": true, + "public_key": tls.Reality.PublicKey, + "short_id": tls.Reality.ShortID, + } + if tls.Reality.Fingerprint != "" { + tlsConfig["utls"] = map[string]any{ + "enabled": true, + "fingerprint": tls.Reality.Fingerprint, + } + } + } + outbound["tls"] = tlsConfig +} + +func errConfig(message string) error { + return &configError{message: message} +} + +type configError struct { + message string +} + +func (e *configError) Error() string { + return e.message +} + +func applyTransport(outbound map[string]any, transport *models.Transport) { + if transport == nil { + return + } + outbound["transport"] = map[string]any{ + "type": transport.Type, + "path": transport.Path, + } +} diff --git a/internal/config/policy.go b/internal/config/policy.go new file mode 100644 index 0000000..bcf8f71 --- /dev/null +++ b/internal/config/policy.go @@ -0,0 +1,102 @@ +package config + +import "vpnem/internal/models" + +var defaultBlockedDomains = []string{ + "telegram.org", "t.me", "telegram.me", "telegra.ph", "telegram.dog", + "web.telegram.org", + "discord.com", "discord.gg", "discordapp.com", "discordapp.net", + "instagram.com", "cdninstagram.com", "ig.me", "igcdn.com", + "facebook.com", "fb.com", "fbcdn.net", "fbsbx.com", "fb.me", + "whatsapp.com", "whatsapp.net", + "twitter.com", "x.com", "twimg.com", "t.co", + "openai.com", "chatgpt.com", "oaistatic.com", "oaiusercontent.com", + "claude.ai", "anthropic.com", + "youtube.com", "googlevideo.com", "youtu.be", "ggpht.com", "ytimg.com", + "gstatic.com", "doubleclick.net", "googleadservices.com", + "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", + "xcdnpro.com", "mmcdn.com", "vscdns.com", "bgicdn.com", "bgmicdn.com", + "doppiocdn.com", "doppiocdn.net", "doppiostreams.com", + "fanclubs.tech", "my.club", "chapturist.com", + "moengage.com", "amplitude.com", "dwin1.com", + "eizzih.com", "loo3laej.com", "iesnare.com", + "hytto.com", "zendesk.com", + "lovense.com", "lovense-api.com", "lovense.club", + "bitrix24.ru", "bitrix24.com", + "cloudflare.com", + "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", + "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", + "em-mail.ru", +} + +func DefaultRoutingPolicy() *models.RoutingPolicy { + return &models.RoutingPolicy{ + Version: "2026-04-04", + AlwaysDirectProcesses: append([]string{}, BypassProcesses...), + PreferDirectProcesses: append([]string{}, PreferDirectProcesses...), + ProxyableBrowserProcesses: append([]string{}, ProxyableBrowserProcesses...), + LovenseProcessRegex: append([]string{}, LovenseProcessRegex...), + StaticBypassIPs: append([]string{}, StaticBypassIPs...), + ReservedCIDRs: append([]string{}, ReservedCIDRs...), + LocalDomainSuffixes: append([]string{}, LocalDomainSuffixes...), + WindowsNCSIDomains: append([]string{}, WindowsNCSIDomains...), + InfraBypassDomains: []string{"em-sysadmin.xyz"}, + ForcedProxyIPs: append([]string{}, ForcedProxyIPs...), + TelegramProcesses: append([]string{}, TelegramProcesses...), + TelegramProcessRegex: append([]string{}, TelegramProcessRegex...), + TelegramDomains: append([]string{}, TelegramDomains...), + TelegramDomainRegex: append([]string{}, TelegramDomainRegex...), + TelegramIPs: append([]string{}, TelegramIPs...), + BlockedDomains: append([]string{}, defaultBlockedDomains...), + ProxyDNSDomains: append([]string{}, ProxyDNSDomains...), + IPCheckDomains: append([]string{}, IPCheckDomains...), + } +} + +func EffectiveRoutingPolicy(policy *models.RoutingPolicy) *models.RoutingPolicy { + if policy == nil { + return DefaultRoutingPolicy() + } + + effective := *DefaultRoutingPolicy() + if policy.Version != "" { + effective.Version = policy.Version + } + overrideStringSlice(&effective.AlwaysDirectProcesses, policy.AlwaysDirectProcesses) + overrideStringSlice(&effective.PreferDirectProcesses, policy.PreferDirectProcesses) + overrideStringSlice(&effective.ProxyableBrowserProcesses, policy.ProxyableBrowserProcesses) + overrideStringSlice(&effective.LovenseProcessRegex, policy.LovenseProcessRegex) + overrideStringSlice(&effective.StaticBypassIPs, policy.StaticBypassIPs) + overrideStringSlice(&effective.ReservedCIDRs, policy.ReservedCIDRs) + overrideStringSlice(&effective.LocalDomainSuffixes, policy.LocalDomainSuffixes) + overrideStringSlice(&effective.WindowsNCSIDomains, policy.WindowsNCSIDomains) + overrideStringSlice(&effective.InfraBypassDomains, policy.InfraBypassDomains) + overrideStringSlice(&effective.ForcedProxyIPs, policy.ForcedProxyIPs) + overrideStringSlice(&effective.TelegramProcesses, policy.TelegramProcesses) + overrideStringSlice(&effective.TelegramProcessRegex, policy.TelegramProcessRegex) + overrideStringSlice(&effective.TelegramDomains, policy.TelegramDomains) + overrideStringSlice(&effective.TelegramDomainRegex, policy.TelegramDomainRegex) + overrideStringSlice(&effective.TelegramIPs, policy.TelegramIPs) + overrideStringSlice(&effective.BlockedDomains, policy.BlockedDomains) + overrideStringSlice(&effective.ProxyDNSDomains, policy.ProxyDNSDomains) + overrideStringSlice(&effective.IPCheckDomains, policy.IPCheckDomains) + return &effective +} + +func overrideStringSlice(dst *[]string, src []string) { + if src == nil { + return + } + *dst = append([]string{}, src...) +} |
