summaryrefslogtreecommitdiff
path: root/internal/config
diff options
context:
space:
mode:
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/builder.go340
-rw-r--r--internal/config/builder_test.go431
-rw-r--r--internal/config/bypass.go169
-rw-r--r--internal/config/modes.go176
-rw-r--r--internal/config/outbounds.go212
-rw-r--r--internal/config/policy.go102
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...)
+}