1875 lines
50 KiB
Go
1875 lines
50 KiB
Go
package config
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed russian_service_direct_cidrs.txt
|
|
var russianServiceDirectCIDRsText string
|
|
|
|
type storedProfileNode struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Group string `json:"group"`
|
|
Protocol string `json:"protocol"`
|
|
Server string `json:"server"`
|
|
Port int `json:"port"`
|
|
Outbound json.RawMessage `json:"outbound"`
|
|
OwnerPackage string `json:"ownerPackage"`
|
|
Stale bool `json:"stale"`
|
|
}
|
|
|
|
type ProfileNodeSkip struct {
|
|
Index int `json:"index"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type RuntimeProfileNodeReport struct {
|
|
Total int `json:"total"`
|
|
Usable int `json:"usable"`
|
|
Renderable int `json:"renderable"`
|
|
Skipped []ProfileNodeSkip `json:"skipped,omitempty"`
|
|
}
|
|
|
|
// RenderSingboxConfig generates a complete sing-box configuration JSON
|
|
// from the canonical Config and a resolved NodeProfile.
|
|
func RenderSingboxConfig(cfg *Config, profile *NodeProfile) ([]byte, error) {
|
|
return renderSingboxConfig(cfg, profile, "")
|
|
}
|
|
|
|
func RenderSingboxConfigForDataDir(cfg *Config, profile *NodeProfile, dataDir string) ([]byte, error) {
|
|
cachePath := ""
|
|
if strings.TrimSpace(dataDir) != "" {
|
|
cachePath = filepath.Join(dataDir, "data", "cache.db")
|
|
}
|
|
return renderSingboxConfig(cfg, profile, cachePath)
|
|
}
|
|
|
|
func renderSingboxConfig(cfg *Config, profile *NodeProfile, cacheFilePath string) ([]byte, error) {
|
|
route := buildRoute(cfg)
|
|
sbCfg := map[string]interface{}{
|
|
"log": map[string]interface{}{
|
|
"level": normalizeLogLevel(cfg.LogLevel),
|
|
"timestamp": true,
|
|
},
|
|
"dns": buildDNS(cfg, profile),
|
|
"inbounds": buildInbounds(cfg),
|
|
"route": route,
|
|
}
|
|
if _, ok := route["rule_set"]; ok {
|
|
cacheFile := map[string]interface{}{
|
|
"enabled": true,
|
|
}
|
|
if strings.TrimSpace(cacheFilePath) != "" {
|
|
cacheFile["path"] = cacheFilePath
|
|
}
|
|
ensureExperimental(sbCfg)["cache_file"] = cacheFile
|
|
}
|
|
if cfg.Proxy.APIPort > 0 {
|
|
secret := strings.TrimSpace(cfg.Proxy.APISecret)
|
|
if secret == "" {
|
|
return nil, fmt.Errorf("renderer: proxy.api_secret is required when proxy.api_port is enabled")
|
|
}
|
|
experimental := ensureExperimental(sbCfg)
|
|
experimental["clash_api"] = map[string]interface{}{
|
|
"external_controller": fmt.Sprintf("127.0.0.1:%d", cfg.Proxy.APIPort),
|
|
"secret": secret,
|
|
}
|
|
}
|
|
outbounds, err := buildOutbounds(cfg, profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sbCfg["outbounds"] = outbounds
|
|
|
|
data, err := json.MarshalIndent(sbCfg, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("renderer: marshal: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func buildDNS(cfg *Config, profile *NodeProfile) map[string]interface{} {
|
|
remoteDetour := "proxy"
|
|
if RequiresXraySidecar(profile) {
|
|
remoteDetour = "direct"
|
|
}
|
|
servers := []map[string]interface{}{
|
|
buildDNSServer("remote-dns", cfg.DNS.ProxyDNS, remoteDetour),
|
|
buildDNSServer("direct-dns", cfg.DNS.DirectDNS, "direct"),
|
|
{
|
|
"type": "udp",
|
|
"tag": "bootstrap-dns",
|
|
"server": cfg.DNS.BootstrapIP,
|
|
},
|
|
}
|
|
|
|
rules := []map[string]interface{}{}
|
|
|
|
endpointDomains, _ := proxyEndpointRules(cfg)
|
|
if len(endpointDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": endpointDomains,
|
|
"action": "route",
|
|
"server": "direct-dns",
|
|
})
|
|
}
|
|
|
|
if cfg.Routing.BlockAds {
|
|
rules = append(rules, map[string]interface{}{
|
|
"rule_set": []string{"geosite-category-ads-all"},
|
|
"action": "predefined",
|
|
"rcode": "NOERROR",
|
|
})
|
|
}
|
|
|
|
if cfg.Routing.BypassRussia {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain_suffix": russianDomainSuffixes(),
|
|
"action": "route",
|
|
"server": "direct-dns",
|
|
})
|
|
}
|
|
|
|
if cfg.Routing.BypassChina {
|
|
rules = append(rules, map[string]interface{}{
|
|
"rule_set": []string{"geosite-cn"},
|
|
"action": "route",
|
|
"server": "direct-dns",
|
|
})
|
|
}
|
|
|
|
customDirectDomains, _ := splitRuleInputs(cfg.Routing.CustomDirect)
|
|
customBlockDomains, _ := splitRuleInputs(cfg.Routing.CustomBlock)
|
|
|
|
// Custom direct domains use direct DNS.
|
|
if len(customDirectDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": customDirectDomains,
|
|
"action": "route",
|
|
"server": "direct-dns",
|
|
})
|
|
}
|
|
|
|
// Custom blocked domains use block DNS.
|
|
if len(customBlockDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": customBlockDomains,
|
|
"action": "predefined",
|
|
"rcode": "NOERROR",
|
|
})
|
|
}
|
|
|
|
dns := map[string]interface{}{
|
|
"servers": servers,
|
|
"rules": rules,
|
|
"final": "remote-dns",
|
|
}
|
|
|
|
if cfg.DNS.FakeIP {
|
|
servers = append(servers, map[string]interface{}{
|
|
"type": "fakeip",
|
|
"tag": "fakeip",
|
|
"inet4_range": "198.18.0.0/15",
|
|
"inet6_range": "fc00::/18",
|
|
})
|
|
dns["servers"] = servers
|
|
dns["rules"] = append(rules, map[string]interface{}{
|
|
"query_type": []string{"A", "AAAA"},
|
|
"server": "fakeip",
|
|
})
|
|
}
|
|
|
|
applyDNSIPv6Mode(dns, cfg.IPv6.Mode)
|
|
return dns
|
|
}
|
|
|
|
func applyDNSIPv6Mode(dns map[string]interface{}, mode string) {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "prefer_ipv4":
|
|
dns["strategy"] = "prefer_ipv4"
|
|
case "prefer_ipv6":
|
|
dns["strategy"] = "prefer_ipv6"
|
|
case "ipv6_only":
|
|
dns["strategy"] = "ipv6_only"
|
|
case "disable", "disabled", "off", "ipv4_only":
|
|
dns["strategy"] = "ipv4_only"
|
|
rules, _ := dns["rules"].([]map[string]interface{})
|
|
dns["rules"] = append([]map[string]interface{}{
|
|
{
|
|
"query_type": []string{"AAAA"},
|
|
"action": "predefined",
|
|
"rcode": "NOERROR",
|
|
},
|
|
}, rules...)
|
|
}
|
|
}
|
|
|
|
func buildDNSServer(tag, address, detour string) map[string]interface{} {
|
|
server := map[string]interface{}{
|
|
"tag": tag,
|
|
}
|
|
if detour != "" && detour != "direct" {
|
|
server["detour"] = detour
|
|
}
|
|
|
|
parsed, err := url.Parse(address)
|
|
if err != nil || parsed.Scheme == "" {
|
|
server["type"] = "udp"
|
|
server["server"] = address
|
|
return server
|
|
}
|
|
|
|
switch parsed.Scheme {
|
|
case "https", "h3":
|
|
server["type"] = parsed.Scheme
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
host = parsed.Host
|
|
}
|
|
server["server"] = host
|
|
if port := parsed.Port(); port != "" {
|
|
if parsedPort, err := strconv.Atoi(port); err == nil {
|
|
server["server_port"] = parsedPort
|
|
}
|
|
}
|
|
if parsed.Path != "" && parsed.Path != "/dns-query" {
|
|
server["path"] = parsed.Path
|
|
}
|
|
if net.ParseIP(host) == nil {
|
|
server["domain_resolver"] = "bootstrap-dns"
|
|
}
|
|
case "tls", "quic":
|
|
server["type"] = parsed.Scheme
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
host = parsed.Host
|
|
}
|
|
server["server"] = host
|
|
if port := parsed.Port(); port != "" {
|
|
if parsedPort, err := strconv.Atoi(port); err == nil {
|
|
server["server_port"] = parsedPort
|
|
}
|
|
}
|
|
if net.ParseIP(host) == nil {
|
|
server["domain_resolver"] = "bootstrap-dns"
|
|
}
|
|
case "tcp", "udp":
|
|
server["type"] = parsed.Scheme
|
|
server["server"] = parsed.Hostname()
|
|
if server["server"] == "" {
|
|
server["server"] = parsed.Host
|
|
}
|
|
if port := parsed.Port(); port != "" {
|
|
if parsedPort, err := strconv.Atoi(port); err == nil {
|
|
server["server_port"] = parsedPort
|
|
}
|
|
}
|
|
default:
|
|
server["type"] = "udp"
|
|
server["server"] = address
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
func buildInbounds(cfg *Config) []map[string]interface{} {
|
|
profileInbounds := cfg.ResolveProfileInbounds()
|
|
helperListen := "127.0.0.1"
|
|
if profileInbounds.AllowLAN {
|
|
helperListen = "0.0.0.0"
|
|
}
|
|
inbounds := []map[string]interface{}{
|
|
{
|
|
"type": "tproxy",
|
|
"tag": "tproxy-in",
|
|
"listen": "::",
|
|
"listen_port": cfg.Proxy.TProxyPort,
|
|
},
|
|
{
|
|
"type": "direct",
|
|
"tag": "dns-in",
|
|
"listen": "::",
|
|
"listen_port": cfg.Proxy.DNSPort,
|
|
"override_address": firstNonEmpty(cfg.DNS.BootstrapIP, "1.1.1.1"),
|
|
"override_port": 53,
|
|
},
|
|
}
|
|
if profileInbounds.HTTPPort > 0 {
|
|
inbounds = append(inbounds, map[string]interface{}{
|
|
"type": "http",
|
|
"tag": "status-http-in",
|
|
"listen": helperListen,
|
|
"listen_port": profileInbounds.HTTPPort,
|
|
})
|
|
}
|
|
if profileInbounds.SocksPort > 0 {
|
|
inbounds = append(inbounds, map[string]interface{}{
|
|
"type": "socks",
|
|
"tag": "status-socks-in",
|
|
"listen": helperListen,
|
|
"listen_port": profileInbounds.SocksPort,
|
|
})
|
|
}
|
|
return inbounds
|
|
}
|
|
|
|
func buildOutbounds(cfg *Config, profile *NodeProfile) ([]map[string]interface{}, error) {
|
|
nodeProfiles := ProfilesFromConfigNodes(cfg)
|
|
if len(nodeProfiles) > 0 {
|
|
var err error
|
|
nodeProfiles, err = renderableProfileNodes(cfg, nodeProfiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(nodeProfiles) == 0 {
|
|
if profile.Address == "" {
|
|
return nil, fmt.Errorf("renderer: node address is empty")
|
|
}
|
|
if profile.Port == 0 {
|
|
return nil, fmt.Errorf("renderer: node port is zero")
|
|
}
|
|
profile.Tag = "proxy"
|
|
nodeProfiles = []*NodeProfile{profile}
|
|
}
|
|
|
|
outbounds := []map[string]interface{}{}
|
|
nodeTags := make([]string, 0, len(nodeProfiles))
|
|
for index, nodeProfile := range nodeProfiles {
|
|
if len(nodeProfiles) == 1 {
|
|
nodeProfile.Tag = "proxy"
|
|
} else if nodeProfile.Tag == "" {
|
|
nodeProfile.Tag = fmt.Sprintf("node-%d", index+1)
|
|
}
|
|
}
|
|
groupPlans := buildGroupOutboundPlans(nodeProfiles)
|
|
for _, nodeProfile := range nodeProfiles {
|
|
proxyOut, err := buildProxyOutbound(nodeProfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outbounds = append(outbounds, proxyOut)
|
|
nodeTags = append(nodeTags, nodeProfile.Tag)
|
|
}
|
|
|
|
if len(nodeTags) > 1 {
|
|
testURL := cfg.Health.URL
|
|
if testURL == "" {
|
|
testURL = "https://www.gstatic.com/generate_204"
|
|
}
|
|
outbounds = append(outbounds, map[string]interface{}{
|
|
"type": "urltest",
|
|
"tag": "auto",
|
|
"outbounds": nodeTags,
|
|
"url": testURL,
|
|
"interval": "3m",
|
|
"tolerance": 50,
|
|
"interrupt_exist_connections": true,
|
|
})
|
|
selectorOutbounds := append([]string{"auto"}, nodeTags...)
|
|
for _, groupPlan := range groupPlans {
|
|
selectorOutbounds = append(selectorOutbounds, "group-"+groupPlan.base)
|
|
}
|
|
selectorDefault := "auto"
|
|
if activeTag := activeProfileNodeTag(cfg, nodeProfiles); activeTag != "" {
|
|
selectorDefault = activeTag
|
|
}
|
|
outbounds = append(outbounds, map[string]interface{}{
|
|
"type": "selector",
|
|
"tag": "proxy",
|
|
"outbounds": selectorOutbounds,
|
|
"default": selectorDefault,
|
|
"interrupt_exist_connections": true,
|
|
})
|
|
}
|
|
outbounds = append(outbounds, buildGroupOutbounds(cfg, groupPlans)...)
|
|
|
|
outbounds = append(outbounds,
|
|
map[string]interface{}{
|
|
"type": "direct",
|
|
"tag": "direct",
|
|
},
|
|
)
|
|
return outbounds, nil
|
|
}
|
|
|
|
type groupOutboundPlan struct {
|
|
name string
|
|
base string
|
|
tags []string
|
|
}
|
|
|
|
func buildGroupOutboundPlans(profiles []*NodeProfile) []groupOutboundPlan {
|
|
plans := []groupOutboundPlan{}
|
|
byName := map[string]int{}
|
|
usedBases := map[string]int{}
|
|
|
|
for _, profile := range profiles {
|
|
group := strings.TrimSpace(profile.Group)
|
|
if group == "" || profile.Tag == "" {
|
|
continue
|
|
}
|
|
index, ok := byName[group]
|
|
if !ok {
|
|
base := sanitizeOutboundTagSuffix(group)
|
|
if base == "" {
|
|
base = "default"
|
|
}
|
|
if count := usedBases[base]; count > 0 {
|
|
base = fmt.Sprintf("%s-%d", base, count+1)
|
|
}
|
|
usedBases[base]++
|
|
index = len(plans)
|
|
byName[group] = index
|
|
plans = append(plans, groupOutboundPlan{name: group, base: base})
|
|
}
|
|
plans[index].tags = append(plans[index].tags, profile.Tag)
|
|
}
|
|
|
|
return plans
|
|
}
|
|
|
|
func buildGroupOutbounds(cfg *Config, plans []groupOutboundPlan) []map[string]interface{} {
|
|
if len(plans) == 0 {
|
|
return nil
|
|
}
|
|
testURL := cfg.Health.URL
|
|
if testURL == "" {
|
|
testURL = "https://www.gstatic.com/generate_204"
|
|
}
|
|
|
|
outbounds := make([]map[string]interface{}, 0, len(plans)*2)
|
|
for _, plan := range plans {
|
|
if len(plan.tags) == 0 {
|
|
continue
|
|
}
|
|
selectorTag := "group-" + plan.base
|
|
autoTag := selectorTag + "-auto"
|
|
selectorOutbounds := append([]string{}, plan.tags...)
|
|
selectorDefault := plan.tags[0]
|
|
if len(plan.tags) > 1 {
|
|
outbounds = append(outbounds, map[string]interface{}{
|
|
"type": "urltest",
|
|
"tag": autoTag,
|
|
"outbounds": plan.tags,
|
|
"url": testURL,
|
|
"interval": "3m",
|
|
"tolerance": 50,
|
|
"interrupt_exist_connections": true,
|
|
})
|
|
selectorOutbounds = append([]string{autoTag}, plan.tags...)
|
|
selectorDefault = autoTag
|
|
}
|
|
outbounds = append(outbounds, map[string]interface{}{
|
|
"type": "selector",
|
|
"tag": selectorTag,
|
|
"outbounds": selectorOutbounds,
|
|
"default": selectorDefault,
|
|
"interrupt_exist_connections": true,
|
|
})
|
|
}
|
|
return outbounds
|
|
}
|
|
|
|
func activeProfileNodeTag(cfg *Config, profiles []*NodeProfile) string {
|
|
activeID := strings.TrimSpace(cfg.Profile.ActiveNodeID)
|
|
if activeID == "" {
|
|
return ""
|
|
}
|
|
for _, profile := range profiles {
|
|
if profile.ID == activeID && profile.Tag != "" {
|
|
return profile.Tag
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildProxyOutbound(profile *NodeProfile) (map[string]interface{}, error) {
|
|
if RequiresXraySidecar(profile) {
|
|
sidecarProfile := *profile
|
|
sidecarProfile.Protocol = "socks"
|
|
sidecarProfile.Address = "127.0.0.1"
|
|
sidecarProfile.Port = XraySidecarSocksPort
|
|
sidecarProfile.Transport = "tcp"
|
|
sidecarProfile.TLSServer = ""
|
|
sidecarProfile.RealityPubKey = ""
|
|
sidecarProfile.RealityShortID = ""
|
|
sidecarProfile.Extra = nil
|
|
return buildProxyOutbound(&sidecarProfile)
|
|
}
|
|
|
|
tag := profile.Tag
|
|
if tag == "" {
|
|
tag = "proxy"
|
|
}
|
|
out := map[string]interface{}{
|
|
"tag": tag,
|
|
"server": profile.Address,
|
|
"server_port": profile.Port,
|
|
}
|
|
if isDomainAddress(profile.Address) {
|
|
out["domain_resolver"] = "direct-dns"
|
|
}
|
|
|
|
switch profile.Protocol {
|
|
case "vless":
|
|
out["type"] = "vless"
|
|
if profile.UUID == "" {
|
|
return nil, fmt.Errorf("renderer: vless uuid is empty")
|
|
}
|
|
out["uuid"] = profile.UUID
|
|
if flow := singBoxVLESSFlow(profile.Flow); flow != "" {
|
|
out["flow"] = flow
|
|
}
|
|
if encoding := vlessPacketEncoding(profile); encoding != "" {
|
|
out["packet_encoding"] = encoding
|
|
}
|
|
if tls := buildTLS(profile, false); tls != nil {
|
|
out["tls"] = tls
|
|
}
|
|
|
|
case "trojan":
|
|
out["type"] = "trojan"
|
|
password := profile.Password
|
|
if password == "" {
|
|
password = profile.UUID
|
|
}
|
|
if password == "" {
|
|
return nil, fmt.Errorf("renderer: trojan password is empty")
|
|
}
|
|
out["password"] = password
|
|
if tls := buildTLS(profile, true); tls != nil {
|
|
out["tls"] = tls
|
|
}
|
|
|
|
case "vmess":
|
|
out["type"] = "vmess"
|
|
if profile.UUID == "" {
|
|
return nil, fmt.Errorf("renderer: vmess uuid is empty")
|
|
}
|
|
out["uuid"] = profile.UUID
|
|
out["alter_id"] = profile.AlterID
|
|
sec := profile.Security
|
|
if sec == "" {
|
|
sec = "auto"
|
|
}
|
|
out["security"] = sec
|
|
if tls := buildTLS(profile, false); tls != nil {
|
|
out["tls"] = tls
|
|
}
|
|
|
|
case "shadowsocks":
|
|
out["type"] = "shadowsocks"
|
|
password := profile.Password
|
|
if password == "" {
|
|
password = profile.UUID
|
|
}
|
|
if password == "" {
|
|
return nil, fmt.Errorf("renderer: shadowsocks password is empty")
|
|
}
|
|
out["password"] = password
|
|
method := profile.SSMethod
|
|
if method == "" {
|
|
method = "2022-blake3-aes-128-gcm"
|
|
}
|
|
method = normalizeShadowsocksMethod(method)
|
|
if !isSupportedShadowsocksMethod(method) {
|
|
return nil, fmt.Errorf("renderer: shadowsocks method %q is not supported by sing-box", profile.SSMethod)
|
|
}
|
|
out["method"] = method
|
|
if profile.SSPlugin != "" {
|
|
out["plugin"] = profile.SSPlugin
|
|
}
|
|
if profile.SSPluginOpts != "" {
|
|
out["plugin_opts"] = profile.SSPluginOpts
|
|
}
|
|
|
|
case "socks":
|
|
out["type"] = "socks"
|
|
out["version"] = valueOrDefault(profile.SocksVersion, "5")
|
|
if profile.Username != "" {
|
|
out["username"] = profile.Username
|
|
}
|
|
if profile.Password != "" {
|
|
out["password"] = profile.Password
|
|
}
|
|
if profile.Network != "" {
|
|
out["network"] = profile.Network
|
|
}
|
|
|
|
case "hysteria2":
|
|
out["type"] = "hysteria2"
|
|
password := profile.Password
|
|
if password == "" {
|
|
password = profile.UUID
|
|
}
|
|
if password == "" {
|
|
return nil, fmt.Errorf("renderer: hysteria2 password is empty")
|
|
}
|
|
out["password"] = password
|
|
if len(profile.ServerPorts) > 0 {
|
|
out["server_ports"] = profile.ServerPorts
|
|
}
|
|
if profile.ObfsType != "" || profile.ObfsPassword != "" {
|
|
out["obfs"] = map[string]interface{}{
|
|
"type": valueOrDefault(profile.ObfsType, "salamander"),
|
|
"password": profile.ObfsPassword,
|
|
}
|
|
}
|
|
if tls := buildTLS(profile, true); tls != nil {
|
|
out["tls"] = tls
|
|
}
|
|
if value := profile.Extra["network"]; value != "" {
|
|
out["network"] = value
|
|
}
|
|
|
|
case "tuic":
|
|
out["type"] = "tuic"
|
|
if profile.UUID == "" || profile.Password == "" {
|
|
return nil, fmt.Errorf("renderer: tuic uuid/password is empty")
|
|
}
|
|
out["uuid"] = profile.UUID
|
|
out["password"] = profile.Password
|
|
if value := profile.Extra["congestion_control"]; value != "" {
|
|
out["congestion_control"] = value
|
|
}
|
|
if value := profile.Extra["udp_relay_mode"]; value != "" {
|
|
out["udp_relay_mode"] = value
|
|
}
|
|
if value := profile.Extra["udp_over_stream"]; value != "" {
|
|
out["udp_over_stream"] = parseBool(value)
|
|
}
|
|
if value := profile.Extra["zero_rtt_handshake"]; value != "" {
|
|
out["zero_rtt_handshake"] = parseBool(value)
|
|
}
|
|
if value := profile.Extra["heartbeat"]; value != "" {
|
|
out["heartbeat"] = value
|
|
}
|
|
if tls := buildTLS(profile, true); tls != nil {
|
|
out["tls"] = tls
|
|
}
|
|
if value := profile.Extra["network"]; value != "" {
|
|
out["network"] = value
|
|
}
|
|
case "wireguard":
|
|
out["type"] = "wireguard"
|
|
if profile.WGPrivateKey == "" {
|
|
return nil, fmt.Errorf("renderer: wireguard private key is empty")
|
|
}
|
|
if profile.WGPeerPublicKey == "" {
|
|
return nil, fmt.Errorf("renderer: wireguard peer public key is empty")
|
|
}
|
|
if len(profile.WGLocalAddress) == 0 {
|
|
return nil, fmt.Errorf("renderer: wireguard local address is empty")
|
|
}
|
|
out["private_key"] = profile.WGPrivateKey
|
|
out["peer_public_key"] = profile.WGPeerPublicKey
|
|
out["local_address"] = profile.WGLocalAddress
|
|
if profile.WGPresharedKey != "" {
|
|
out["pre_shared_key"] = profile.WGPresharedKey
|
|
}
|
|
if profile.WGMTU > 0 {
|
|
out["mtu"] = profile.WGMTU
|
|
}
|
|
if len(profile.WGReserved) > 0 {
|
|
out["reserved"] = profile.WGReserved
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("renderer: unsupported protocol %q", profile.Protocol)
|
|
}
|
|
|
|
// Transport layer (WebSocket, gRPC, HTTP/2). SOCKS is already a complete
|
|
// outbound and must not inherit VLESS/Trojan transport settings.
|
|
if profile.Protocol != "socks" && profile.Protocol != "wireguard" {
|
|
tp, err := buildTransport(profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tp != nil {
|
|
out["transport"] = tp
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func normalizeShadowsocksMethod(method string) string {
|
|
return strings.ToLower(strings.TrimSpace(method))
|
|
}
|
|
|
|
func isSupportedShadowsocksMethod(method string) bool {
|
|
switch normalizeShadowsocksMethod(method) {
|
|
case "2022-blake3-aes-128-gcm",
|
|
"2022-blake3-aes-256-gcm",
|
|
"2022-blake3-chacha20-poly1305",
|
|
"none",
|
|
"aes-128-gcm",
|
|
"aes-192-gcm",
|
|
"aes-256-gcm",
|
|
"chacha20-ietf-poly1305",
|
|
"xchacha20-ietf-poly1305",
|
|
"aes-128-ctr",
|
|
"aes-192-ctr",
|
|
"aes-256-ctr",
|
|
"aes-128-cfb",
|
|
"aes-192-cfb",
|
|
"aes-256-cfb",
|
|
"rc4-md5",
|
|
"chacha20-ietf",
|
|
"xchacha20":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ProfilesFromConfigNodes(cfg *Config) []*NodeProfile {
|
|
profiles, _ := usableProfileNodes(cfg)
|
|
return profiles
|
|
}
|
|
|
|
func RuntimeProfileNodeDiagnostics(cfg *Config) RuntimeProfileNodeReport {
|
|
report := RuntimeProfileNodeReport{}
|
|
if cfg == nil {
|
|
return report
|
|
}
|
|
profiles, skipped := usableProfileNodes(cfg)
|
|
report.Total = len(cfg.Profile.Nodes)
|
|
report.Usable = len(profiles)
|
|
report.Skipped = append(report.Skipped, skipped...)
|
|
if len(profiles) == 0 {
|
|
return report
|
|
}
|
|
renderable, renderSkipped, _ := renderableProfileNodesWithSkips(cfg, profiles)
|
|
report.Renderable = len(renderable)
|
|
report.Skipped = append(report.Skipped, renderSkipped...)
|
|
return report
|
|
}
|
|
|
|
func usableProfileNodes(cfg *Config) ([]*NodeProfile, []ProfileNodeSkip) {
|
|
if cfg == nil {
|
|
return nil, nil
|
|
}
|
|
profiles := make([]*NodeProfile, 0, len(cfg.Profile.Nodes))
|
|
skipped := make([]ProfileNodeSkip, 0)
|
|
for index, raw := range cfg.Profile.Nodes {
|
|
profile, err := profileFromStoredNode(raw, index)
|
|
if err != nil {
|
|
skipped = append(skipped, profileNodeSkip(index, nil, "cannot decode stored node: "+err.Error()))
|
|
continue
|
|
}
|
|
switch {
|
|
case profile.Stale:
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "node is stale"))
|
|
case profile.Address == "":
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "server is empty"))
|
|
case profile.Port == 0:
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "port is zero"))
|
|
case profile.Protocol == "":
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "protocol is empty"))
|
|
default:
|
|
profiles = append(profiles, profile)
|
|
}
|
|
}
|
|
return profiles, skipped
|
|
}
|
|
|
|
func profileNodeSkip(index int, profile *NodeProfile, reason string) ProfileNodeSkip {
|
|
skip := ProfileNodeSkip{
|
|
Index: index,
|
|
Reason: reason,
|
|
}
|
|
if profile != nil {
|
|
skip.ID = profile.ID
|
|
skip.Name = profile.Name
|
|
}
|
|
return skip
|
|
}
|
|
|
|
// ResolveActiveProfile returns the selected profile node profile, or the
|
|
// first valid profile node when the selected one is absent. It returns nil when
|
|
// profile storage projection is empty or malformed.
|
|
func ResolveActiveProfile(cfg *Config) *NodeProfile {
|
|
profiles := ProfilesFromConfigNodes(cfg)
|
|
if len(profiles) == 0 {
|
|
return nil
|
|
}
|
|
|
|
activeID := cfg.Profile.ActiveNodeID
|
|
for _, profile := range profiles {
|
|
if profile.ID == activeID {
|
|
return profile
|
|
}
|
|
}
|
|
return profiles[0]
|
|
}
|
|
|
|
func isDomainAddress(address string) bool {
|
|
host := strings.Trim(address, "[]")
|
|
return host != "" && net.ParseIP(host) == nil
|
|
}
|
|
|
|
func profileFromStoredNode(raw json.RawMessage, index int) (*NodeProfile, error) {
|
|
var node storedProfileNode
|
|
if err := decodeStrictJSON(raw, &node); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var outbound map[string]interface{}
|
|
if len(node.Outbound) > 0 {
|
|
_ = json.Unmarshal(node.Outbound, &outbound)
|
|
}
|
|
|
|
protocol := normalizeProtocol(node.Protocol)
|
|
if value := stringFromMap(outbound, "protocol"); protocol == "" && value != "" {
|
|
protocol = normalizeProtocol(value)
|
|
}
|
|
profile := &NodeProfile{
|
|
ID: node.ID,
|
|
Name: node.Name,
|
|
Group: strings.TrimSpace(node.Group),
|
|
Tag: profileOutboundTag(node, index),
|
|
Protocol: protocol,
|
|
Address: node.Server,
|
|
Port: node.Port,
|
|
OwnerPackage: strings.TrimSpace(node.OwnerPackage),
|
|
Transport: "tcp",
|
|
Fingerprint: "chrome",
|
|
Extra: map[string]string{},
|
|
Stale: node.Stale,
|
|
RawOutbound: append(json.RawMessage(nil), node.Outbound...),
|
|
}
|
|
|
|
settings := mapFromMap(outbound, "settings")
|
|
switch protocol {
|
|
case "vless", "vmess":
|
|
vnext := firstMapFromArray(settings, "vnext")
|
|
user := firstMapFromArray(vnext, "users")
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(vnext, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(vnext, "port")
|
|
}
|
|
profile.UUID = stringFromMap(user, "id")
|
|
profile.Flow = stringFromMap(user, "flow")
|
|
if protocol == "vless" {
|
|
copyStringExtra(profile.Extra, user, "packet_encoding")
|
|
}
|
|
if protocol == "vmess" {
|
|
profile.AlterID = intFromMap(user, "alterId")
|
|
profile.Security = valueOrDefault(stringFromMap(user, "security"), "auto")
|
|
}
|
|
case "trojan", "shadowsocks":
|
|
server := firstMapFromArray(settings, "servers")
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(server, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(server, "port")
|
|
}
|
|
profile.Password = stringFromMap(server, "password")
|
|
profile.UUID = profile.Password
|
|
if protocol == "shadowsocks" {
|
|
profile.SSMethod = stringFromMap(server, "method")
|
|
profile.SSPlugin = stringFromMap(server, "plugin")
|
|
profile.SSPluginOpts = stringFromMap(server, "plugin_opts")
|
|
}
|
|
case "socks":
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(settings, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(settings, "port")
|
|
}
|
|
profile.Username = stringFromMap(settings, "username")
|
|
profile.Password = stringFromMap(settings, "password")
|
|
profile.SocksVersion = valueOrDefault(stringFromMap(settings, "version"), "5")
|
|
profile.Network = stringFromMap(settings, "network")
|
|
case "hysteria2":
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(settings, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(settings, "port")
|
|
}
|
|
profile.Password = stringFromMap(settings, "password")
|
|
profile.UUID = profile.Password
|
|
profile.ServerPorts = stringSliceFromMap(settings, "server_ports")
|
|
obfs := mapFromMap(settings, "obfs")
|
|
profile.ObfsType = stringFromMap(obfs, "type")
|
|
profile.ObfsPassword = stringFromMap(obfs, "password")
|
|
case "tuic":
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(settings, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(settings, "port")
|
|
}
|
|
profile.UUID = stringFromMap(settings, "uuid")
|
|
profile.Password = stringFromMap(settings, "password")
|
|
copyStringExtra(profile.Extra, settings, "congestion_control")
|
|
copyStringExtra(profile.Extra, settings, "udp_relay_mode")
|
|
copyStringExtra(profile.Extra, settings, "udp_over_stream")
|
|
copyStringExtra(profile.Extra, settings, "zero_rtt_handshake")
|
|
copyStringExtra(profile.Extra, settings, "heartbeat")
|
|
case "wireguard":
|
|
if profile.Address == "" {
|
|
profile.Address = stringFromMap(settings, "address")
|
|
}
|
|
if profile.Port == 0 {
|
|
profile.Port = intFromMap(settings, "port")
|
|
}
|
|
profile.WGPrivateKey = stringFromMap(settings, "private_key")
|
|
profile.WGPeerPublicKey = stringFromMap(settings, "peer_public_key")
|
|
profile.WGPresharedKey = stringFromMap(settings, "pre_shared_key")
|
|
profile.WGLocalAddress = stringSliceFromMap(settings, "local_address")
|
|
profile.WGAllowedIPs = stringFromMap(settings, "allowed_ips")
|
|
profile.WGMTU = intFromMap(settings, "mtu")
|
|
profile.WGReserved = intSliceFromMap(settings, "reserved")
|
|
}
|
|
|
|
applyStreamSettings(profile, mapFromMap(outbound, "streamSettings"))
|
|
normalizeVLESSProfileFlow(profile)
|
|
return profile, nil
|
|
}
|
|
|
|
func renderableProfileNodes(cfg *Config, profiles []*NodeProfile) ([]*NodeProfile, error) {
|
|
renderable, skippedNodes, err := renderableProfileNodesWithSkips(cfg, profiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(renderable) == 0 && len(profiles) > 0 {
|
|
return nil, fmt.Errorf("renderer: no usable profile nodes; skipped %d invalid node(s)", len(skippedNodes))
|
|
}
|
|
return renderable, nil
|
|
}
|
|
|
|
func renderableProfileNodesWithSkips(cfg *Config, profiles []*NodeProfile) ([]*NodeProfile, []ProfileNodeSkip, error) {
|
|
activeID := strings.TrimSpace(cfg.Profile.ActiveNodeID)
|
|
activeProfile := ResolveActiveProfile(cfg)
|
|
activeXHTTPID := ""
|
|
if RequiresXraySidecar(activeProfile) {
|
|
activeXHTTPID = activeProfile.ID
|
|
}
|
|
renderable := make([]*NodeProfile, 0, len(profiles))
|
|
skipped := make([]ProfileNodeSkip, 0)
|
|
for index, profile := range profiles {
|
|
if RequiresXraySidecar(profile) && profile.ID != activeXHTTPID {
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "xhttp sidecar nodes render only when active"))
|
|
continue
|
|
}
|
|
if _, err := buildProxyOutbound(profile); err != nil {
|
|
if activeID != "" && profile.ID == activeID {
|
|
return nil,
|
|
[]ProfileNodeSkip{profileNodeSkip(index, profile, fmt.Sprintf("active node is invalid: %v", err))},
|
|
fmt.Errorf("renderer: active node %q is invalid: %w", firstNonEmpty(profile.Name, profile.ID, profile.Address, profile.Tag), err)
|
|
}
|
|
skipped = append(skipped, profileNodeSkip(index, profile, "invalid sing-box outbound: "+err.Error()))
|
|
continue
|
|
}
|
|
renderable = append(renderable, profile)
|
|
}
|
|
return renderable, skipped, nil
|
|
}
|
|
|
|
func normalizeVLESSProfileFlow(profile *NodeProfile) {
|
|
if profile == nil || profile.Protocol != "vless" {
|
|
return
|
|
}
|
|
flow := strings.TrimSpace(profile.Flow)
|
|
switch strings.ToLower(flow) {
|
|
case "":
|
|
profile.Flow = ""
|
|
case "xtls-rprx-vision":
|
|
profile.Flow = "xtls-rprx-vision"
|
|
case "xtls-rprx-vision-udp443":
|
|
profile.Flow = "xtls-rprx-vision"
|
|
if profile.Extra == nil {
|
|
profile.Extra = map[string]string{}
|
|
}
|
|
if strings.TrimSpace(profile.Extra["packet_encoding"]) == "" {
|
|
profile.Extra["packet_encoding"] = "xudp"
|
|
}
|
|
default:
|
|
profile.Flow = flow
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(profile.Transport), "xhttp") && strings.HasPrefix(strings.ToLower(profile.Flow), "xtls-rprx-vision") {
|
|
profile.Flow = ""
|
|
}
|
|
}
|
|
|
|
func singBoxVLESSFlow(flow string) string {
|
|
switch strings.ToLower(strings.TrimSpace(flow)) {
|
|
case "xtls-rprx-vision", "xtls-rprx-vision-udp443":
|
|
return "xtls-rprx-vision"
|
|
default:
|
|
return strings.TrimSpace(flow)
|
|
}
|
|
}
|
|
|
|
func vlessPacketEncoding(profile *NodeProfile) string {
|
|
if profile == nil || profile.Protocol != "vless" {
|
|
return ""
|
|
}
|
|
if profile.Extra == nil {
|
|
return ""
|
|
}
|
|
packetEncoding := strings.ToLower(strings.TrimSpace(profile.Extra["packet_encoding"]))
|
|
switch packetEncoding {
|
|
case "xudp", "packetaddr":
|
|
return packetEncoding
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func applyStreamSettings(profile *NodeProfile, stream map[string]interface{}) {
|
|
if len(stream) == 0 || profile.Protocol == "socks" {
|
|
return
|
|
}
|
|
network := stringFromMap(stream, "network")
|
|
if network == "" {
|
|
network = "tcp"
|
|
}
|
|
security := stringFromMap(stream, "security")
|
|
profile.Transport = network
|
|
switch security {
|
|
case "tls", "reality":
|
|
profile.Extra["security"] = security
|
|
}
|
|
|
|
if tls := mapFromMap(stream, "tlsSettings"); len(tls) > 0 {
|
|
applyTLSSettings(profile, tls)
|
|
}
|
|
if reality := mapFromMap(stream, "realitySettings"); len(reality) > 0 {
|
|
applyTLSSettings(profile, reality)
|
|
profile.RealityPubKey = stringFromMap(reality, "publicKey")
|
|
profile.RealityShortID = stringFromMap(reality, "shortId")
|
|
profile.Extra["public_key"] = profile.RealityPubKey
|
|
profile.Extra["short_id"] = profile.RealityShortID
|
|
}
|
|
|
|
switch network {
|
|
case "tcp":
|
|
tcpSettings := mapFromMap(stream, "tcpSettings")
|
|
header := mapFromMap(tcpSettings, "header")
|
|
profile.Extra["header_type"] = stringFromMap(header, "type")
|
|
case "ws":
|
|
ws := mapFromMap(stream, "wsSettings")
|
|
profile.Extra["path"] = stringFromMap(ws, "path")
|
|
headers := mapFromMap(ws, "headers")
|
|
profile.Extra["host"] = stringFromMap(headers, "Host")
|
|
case "grpc":
|
|
grpc := mapFromMap(stream, "grpcSettings")
|
|
profile.Extra["service_name"] = stringFromMap(grpc, "serviceName")
|
|
profile.Extra["mode"] = stringFromMap(grpc, "mode")
|
|
profile.Extra["authority"] = stringFromMap(grpc, "authority")
|
|
case "http", "h2":
|
|
httpSettings := mapFromMap(stream, "httpSettings")
|
|
profile.Extra["path"] = stringFromMap(httpSettings, "path")
|
|
profile.Extra["host"] = strings.Join(stringSliceFromMap(httpSettings, "host"), ",")
|
|
case "httpupgrade":
|
|
httpUpgrade := mapFromMap(stream, "httpupgradeSettings")
|
|
profile.Extra["path"] = stringFromMap(httpUpgrade, "path")
|
|
profile.Extra["host"] = stringFromMap(httpUpgrade, "host")
|
|
case "xhttp":
|
|
xhttp := mapFromMap(stream, "xhttpSettings")
|
|
profile.Extra["path"] = stringFromMap(xhttp, "path")
|
|
profile.Extra["host"] = stringFromMap(xhttp, "host")
|
|
profile.Extra["mode"] = stringFromMap(xhttp, "mode")
|
|
case "quic":
|
|
quic := mapFromMap(stream, "quicSettings")
|
|
profile.Extra["quic_security"] = stringFromMap(quic, "security")
|
|
profile.Extra["key"] = stringFromMap(quic, "key")
|
|
profile.Extra["header_type"] = stringFromMap(quic, "header_type")
|
|
}
|
|
if profile.Protocol == "hysteria2" || profile.Protocol == "tuic" {
|
|
profile.Extra["network"] = network
|
|
}
|
|
}
|
|
|
|
func applyTLSSettings(profile *NodeProfile, tls map[string]interface{}) {
|
|
profile.TLSServer = firstNonEmpty(
|
|
stringFromMap(tls, "serverName"),
|
|
stringFromMap(tls, "server_name"),
|
|
)
|
|
profile.Fingerprint = firstNonEmpty(
|
|
stringFromMap(tls, "fingerprint"),
|
|
profile.Fingerprint,
|
|
)
|
|
if alpn := stringSliceFromMap(tls, "alpn"); len(alpn) > 0 {
|
|
profile.Extra["alpn"] = strings.Join(alpn, ",")
|
|
}
|
|
if boolFromMap(tls, "allowInsecure") {
|
|
profile.Extra["insecure"] = "true"
|
|
}
|
|
}
|
|
|
|
func profileOutboundTag(node storedProfileNode, index int) string {
|
|
base := node.ID
|
|
if base == "" {
|
|
base = node.Name
|
|
}
|
|
if base == "" {
|
|
base = fmt.Sprintf("node-%d", index+1)
|
|
}
|
|
base = strings.ToLower(base)
|
|
base = regexp.MustCompile(`[^a-z0-9_-]+`).ReplaceAllString(base, "-")
|
|
base = strings.Trim(base, "-")
|
|
if base == "" {
|
|
base = fmt.Sprintf("node-%d", index+1)
|
|
}
|
|
return "node-" + base
|
|
}
|
|
|
|
func sanitizeOutboundTagSuffix(value string) string {
|
|
base := strings.ToLower(strings.TrimSpace(value))
|
|
base = regexp.MustCompile(`[^a-z0-9_-]+`).ReplaceAllString(base, "-")
|
|
base = strings.Trim(base, "-")
|
|
return base
|
|
}
|
|
|
|
func normalizeProtocol(value string) string {
|
|
switch strings.ToLower(value) {
|
|
case "ss", "shadowsocks":
|
|
return "shadowsocks"
|
|
case "socks", "socks4", "socks4a", "socks5":
|
|
return "socks"
|
|
case "hy2", "hysteria2":
|
|
return "hysteria2"
|
|
case "wg", "wireguard":
|
|
return "wireguard"
|
|
default:
|
|
return strings.ToLower(value)
|
|
}
|
|
}
|
|
|
|
func mapFromMap(source map[string]interface{}, key string) map[string]interface{} {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
if value, ok := source[key].(map[string]interface{}); ok {
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstMapFromArray(source map[string]interface{}, key string) map[string]interface{} {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
values, ok := source[key].([]interface{})
|
|
if !ok || len(values) == 0 {
|
|
return nil
|
|
}
|
|
value, _ := values[0].(map[string]interface{})
|
|
return value
|
|
}
|
|
|
|
func stringFromMap(source map[string]interface{}, key string) string {
|
|
if source == nil {
|
|
return ""
|
|
}
|
|
switch value := source[key].(type) {
|
|
case string:
|
|
return value
|
|
case float64:
|
|
return strconv.Itoa(int(value))
|
|
case json.Number:
|
|
return value.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func intFromMap(source map[string]interface{}, key string) int {
|
|
if source == nil {
|
|
return 0
|
|
}
|
|
switch value := source[key].(type) {
|
|
case float64:
|
|
return int(value)
|
|
case int:
|
|
return value
|
|
case string:
|
|
parsed, _ := strconv.Atoi(value)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func boolFromMap(source map[string]interface{}, key string) bool {
|
|
if source == nil {
|
|
return false
|
|
}
|
|
switch value := source[key].(type) {
|
|
case bool:
|
|
return value
|
|
case string:
|
|
return parseBool(value)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func stringSliceFromMap(source map[string]interface{}, key string) []string {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
values, ok := source[key].([]interface{})
|
|
if !ok {
|
|
if value := stringFromMap(source, key); value != "" {
|
|
return []string{value}
|
|
}
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if str, ok := value.(string); ok && str != "" {
|
|
result = append(result, str)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func intSliceFromMap(source map[string]interface{}, key string) []int {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
values, ok := source[key].([]interface{})
|
|
if !ok {
|
|
value := intFromMap(source, key)
|
|
if value != 0 {
|
|
return []int{value}
|
|
}
|
|
return nil
|
|
}
|
|
result := make([]int, 0, len(values))
|
|
for _, value := range values {
|
|
switch v := value.(type) {
|
|
case int:
|
|
result = append(result, v)
|
|
case float64:
|
|
result = append(result, int(v))
|
|
case json.Number:
|
|
if parsed, err := strconv.Atoi(v.String()); err == nil {
|
|
result = append(result, parsed)
|
|
}
|
|
case string:
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
result = append(result, parsed)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func copyStringExtra(extra map[string]string, source map[string]interface{}, key string) {
|
|
if value := stringFromMap(source, key); value != "" {
|
|
extra[key] = value
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildTLS(profile *NodeProfile, force bool) map[string]interface{} {
|
|
transportSecurity := profile.Extra["security"]
|
|
tlsEnabled := force ||
|
|
profile.Transport == "reality" ||
|
|
profile.RealityPubKey != "" ||
|
|
transportSecurity == "tls" ||
|
|
transportSecurity == "reality"
|
|
if !tlsEnabled {
|
|
return nil
|
|
}
|
|
|
|
tls := map[string]interface{}{
|
|
"enabled": true,
|
|
}
|
|
|
|
if profile.TLSServer != "" {
|
|
tls["server_name"] = profile.TLSServer
|
|
}
|
|
if parseBool(profile.Extra["insecure"]) {
|
|
tls["insecure"] = true
|
|
}
|
|
if alpn := splitCSV(profile.Extra["alpn"]); len(alpn) > 0 {
|
|
tls["alpn"] = alpn
|
|
}
|
|
if pins := splitCSV(profile.Extra["pin_sha256"]); len(pins) > 0 {
|
|
tls["certificate_public_key_sha256"] = pins
|
|
}
|
|
|
|
fp := profile.Fingerprint
|
|
if fp == "" {
|
|
fp = "chrome"
|
|
}
|
|
tls["utls"] = map[string]interface{}{
|
|
"enabled": true,
|
|
"fingerprint": fp,
|
|
}
|
|
|
|
// REALITY TLS settings.
|
|
if profile.Transport == "reality" || profile.RealityPubKey != "" || transportSecurity == "reality" {
|
|
publicKey := profile.RealityPubKey
|
|
if publicKey == "" {
|
|
publicKey = profile.Extra["public_key"]
|
|
}
|
|
shortID := profile.RealityShortID
|
|
if shortID == "" {
|
|
shortID = profile.Extra["short_id"]
|
|
}
|
|
tls["reality"] = map[string]interface{}{
|
|
"enabled": true,
|
|
"public_key": publicKey,
|
|
"short_id": shortID,
|
|
}
|
|
}
|
|
|
|
return tls
|
|
}
|
|
|
|
func buildTransport(profile *NodeProfile) (map[string]interface{}, error) {
|
|
switch profile.Transport {
|
|
case "ws":
|
|
tp := map[string]interface{}{
|
|
"type": "ws",
|
|
}
|
|
if path, ok := profile.Extra["path"]; ok {
|
|
tp["path"] = path
|
|
}
|
|
if host, ok := profile.Extra["host"]; ok {
|
|
tp["headers"] = map[string]interface{}{
|
|
"Host": host,
|
|
}
|
|
}
|
|
return tp, nil
|
|
|
|
case "grpc":
|
|
tp := map[string]interface{}{
|
|
"type": "grpc",
|
|
}
|
|
if sn := profile.Extra["service_name"]; sn != "" {
|
|
tp["service_name"] = sn
|
|
}
|
|
return tp, nil
|
|
|
|
case "http", "h2":
|
|
tp := map[string]interface{}{
|
|
"type": "http",
|
|
}
|
|
if host, ok := profile.Extra["host"]; ok {
|
|
hosts := []string{}
|
|
for _, item := range strings.Split(host, ",") {
|
|
item = strings.TrimSpace(item)
|
|
if item != "" {
|
|
hosts = append(hosts, item)
|
|
}
|
|
}
|
|
if len(hosts) > 0 {
|
|
tp["host"] = hosts
|
|
}
|
|
}
|
|
if path, ok := profile.Extra["path"]; ok {
|
|
tp["path"] = path
|
|
}
|
|
return tp, nil
|
|
|
|
case "tcp":
|
|
if headerType := profile.Extra["header_type"]; headerType != "" && headerType != "none" {
|
|
return nil, fmt.Errorf("renderer: sing-box does not support V2Ray TCP header transport")
|
|
}
|
|
return nil, nil
|
|
|
|
case "quic":
|
|
if security := strings.TrimSpace(profile.Extra["quic_security"]); security != "" && security != "none" {
|
|
return nil, fmt.Errorf("renderer: sing-box QUIC transport does not support V2Ray QUIC security %q", security)
|
|
}
|
|
if key := strings.TrimSpace(profile.Extra["key"]); key != "" {
|
|
return nil, fmt.Errorf("renderer: sing-box QUIC transport does not support V2Ray QUIC key")
|
|
}
|
|
if headerType := strings.TrimSpace(profile.Extra["header_type"]); headerType != "" && headerType != "none" {
|
|
return nil, fmt.Errorf("renderer: sing-box QUIC transport does not support V2Ray QUIC header type %q", headerType)
|
|
}
|
|
return map[string]interface{}{
|
|
"type": "quic",
|
|
}, nil
|
|
|
|
case "httpupgrade":
|
|
tp := map[string]interface{}{
|
|
"type": "httpupgrade",
|
|
}
|
|
if host, ok := profile.Extra["host"]; ok && host != "" {
|
|
tp["host"] = host
|
|
}
|
|
if path, ok := profile.Extra["path"]; ok && path != "" {
|
|
tp["path"] = path
|
|
}
|
|
return tp, nil
|
|
|
|
case "reality":
|
|
return nil, nil
|
|
}
|
|
|
|
if profile.Transport == "" {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("renderer: sing-box does not support V2Ray %s transport", profile.Transport)
|
|
}
|
|
|
|
func buildRoute(cfg *Config) map[string]interface{} {
|
|
rules := []map[string]interface{}{
|
|
{
|
|
"inbound": []string{"dns-in"},
|
|
"action": "hijack-dns",
|
|
},
|
|
{
|
|
"network": []string{"udp", "tcp"},
|
|
"port": 53,
|
|
"action": "hijack-dns",
|
|
},
|
|
{
|
|
"inbound": []string{"tproxy-in"},
|
|
"action": "sniff",
|
|
},
|
|
{
|
|
"protocol": []string{"dns"},
|
|
"action": "hijack-dns",
|
|
},
|
|
{
|
|
"inbound": []string{"tproxy-in"},
|
|
"ip_cidr": []string{"127.0.0.0/8", "::1/128"},
|
|
"action": "reject",
|
|
},
|
|
}
|
|
|
|
endpointDomains, endpointCIDRs := proxyEndpointRules(cfg)
|
|
if len(endpointDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": endpointDomains,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
if len(endpointCIDRs) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"ip_cidr": endpointCIDRs,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
|
|
if rule := buildForcedProxyPackageRule(cfg); rule != nil {
|
|
rules = append(rules, rule)
|
|
}
|
|
for _, rule := range buildAppGroupRouteRules(cfg) {
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
// Bypass private/LAN ranges.
|
|
if cfg.Routing.BypassLAN {
|
|
rules = append(rules, map[string]interface{}{
|
|
"ip_is_private": true,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
|
|
// Block ads via geosite rule set.
|
|
if cfg.Routing.BlockAds {
|
|
rules = append(rules, map[string]interface{}{
|
|
"rule_set": []string{"geosite-category-ads-all"},
|
|
"action": "reject",
|
|
})
|
|
}
|
|
|
|
// Bypass Russia without startup-time network fetches. Remote SRS rule-sets
|
|
// are resolved before the proxy listener is ready, so keep the default
|
|
// Russia bypass fully local.
|
|
if cfg.Routing.BypassRussia {
|
|
rules = append(rules, map[string]interface{}{
|
|
"type": "logical",
|
|
"mode": "or",
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
"rules": []map[string]interface{}{
|
|
{"ip_cidr": russianServiceDirectCIDRs()},
|
|
{"domain_suffix": russianDomainSuffixes()},
|
|
},
|
|
})
|
|
}
|
|
|
|
if cfg.Routing.BypassChina {
|
|
rules = append(rules, map[string]interface{}{
|
|
"rule_set": []string{"geoip-cn", "geosite-cn"},
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
|
|
customDirectDomains, customDirectCIDRs := splitRuleInputs(cfg.Routing.CustomDirect)
|
|
customProxyDomains, customProxyCIDRs := splitRuleInputs(cfg.Routing.CustomProxy)
|
|
customBlockDomains, customBlockCIDRs := splitRuleInputs(cfg.Routing.CustomBlock)
|
|
|
|
// Custom direct domains/IPs.
|
|
if len(customDirectDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": customDirectDomains,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
if len(customDirectCIDRs) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"ip_cidr": customDirectCIDRs,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
|
|
// Custom proxy domains/IPs.
|
|
if len(customProxyDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": customProxyDomains,
|
|
"action": "route",
|
|
"outbound": "proxy",
|
|
})
|
|
}
|
|
if len(customProxyCIDRs) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"ip_cidr": customProxyCIDRs,
|
|
"action": "route",
|
|
"outbound": "proxy",
|
|
})
|
|
}
|
|
|
|
// Custom block domains/IPs.
|
|
if len(customBlockDomains) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"domain": customBlockDomains,
|
|
"action": "reject",
|
|
})
|
|
}
|
|
if len(customBlockCIDRs) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"ip_cidr": customBlockCIDRs,
|
|
"action": "reject",
|
|
})
|
|
}
|
|
|
|
if len(cfg.Routing.AlwaysDirectApps) > 0 {
|
|
rules = append(rules, map[string]interface{}{
|
|
"package_name": cfg.Routing.AlwaysDirectApps,
|
|
"action": "route",
|
|
"outbound": "direct",
|
|
})
|
|
}
|
|
|
|
finalOutbound := "proxy"
|
|
if cfg.Routing.Mode == "direct" {
|
|
finalOutbound = "direct"
|
|
}
|
|
|
|
route := map[string]interface{}{
|
|
"rules": rules,
|
|
"final": finalOutbound,
|
|
"default_domain_resolver": "direct-dns",
|
|
}
|
|
|
|
// Build rule sets for geo databases.
|
|
ruleSets := buildRuleSets(cfg)
|
|
if len(ruleSets) > 0 {
|
|
route["rule_set"] = ruleSets
|
|
}
|
|
|
|
return route
|
|
}
|
|
|
|
func proxyEndpointRules(cfg *Config) (domains []string, cidrs []string) {
|
|
seenDomains := map[string]struct{}{}
|
|
seenCIDRs := map[string]struct{}{}
|
|
|
|
addAddress := func(address string) {
|
|
address = endpointHost(address)
|
|
if address == "" {
|
|
return
|
|
}
|
|
if ip := net.ParseIP(address); ip != nil {
|
|
bits := 32
|
|
if ip.To4() == nil {
|
|
bits = 128
|
|
}
|
|
seenCIDRs[fmt.Sprintf("%s/%d", ip.String(), bits)] = struct{}{}
|
|
return
|
|
}
|
|
seenDomains[strings.ToLower(address)] = struct{}{}
|
|
}
|
|
|
|
for _, profile := range ProfilesFromConfigNodes(cfg) {
|
|
addAddress(profile.Address)
|
|
}
|
|
|
|
domains = make([]string, 0, len(seenDomains))
|
|
for domain := range seenDomains {
|
|
domains = append(domains, domain)
|
|
}
|
|
sort.Strings(domains)
|
|
|
|
cidrs = make([]string, 0, len(seenCIDRs))
|
|
for cidr := range seenCIDRs {
|
|
cidrs = append(cidrs, cidr)
|
|
}
|
|
sort.Strings(cidrs)
|
|
|
|
return domains, cidrs
|
|
}
|
|
|
|
func endpointHost(address string) string {
|
|
address = strings.TrimSpace(address)
|
|
if address == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(address, "://") {
|
|
if parsed, err := url.Parse(address); err == nil {
|
|
address = parsed.Host
|
|
}
|
|
}
|
|
if host, _, err := net.SplitHostPort(address); err == nil {
|
|
address = host
|
|
}
|
|
return strings.Trim(strings.TrimSpace(address), "[]")
|
|
}
|
|
|
|
func buildForcedProxyPackageRule(cfg *Config) map[string]interface{} {
|
|
if cfg == nil || cfg.Routing.Mode != "rules" || cfg.Apps.Mode != "all" {
|
|
return nil
|
|
}
|
|
alwaysDirect := map[string]struct{}{}
|
|
for _, packageName := range cfg.Routing.AlwaysDirectApps {
|
|
packageName = strings.TrimSpace(packageName)
|
|
if packageName != "" {
|
|
alwaysDirect[packageName] = struct{}{}
|
|
}
|
|
}
|
|
groupRouted := map[string]struct{}{}
|
|
for packageName, groupName := range cfg.Apps.AppGroups {
|
|
packageName = strings.TrimSpace(packageName)
|
|
if packageName != "" && strings.TrimSpace(groupName) != "" {
|
|
groupRouted[packageName] = struct{}{}
|
|
}
|
|
}
|
|
packages := uniqueSortedPackages(cfg.Apps.Packages, alwaysDirect, groupRouted)
|
|
if len(packages) == 0 {
|
|
return nil
|
|
}
|
|
return map[string]interface{}{
|
|
"package_name": packages,
|
|
"action": "route",
|
|
"outbound": "proxy",
|
|
}
|
|
}
|
|
|
|
func buildAppGroupRouteRules(cfg *Config) []map[string]interface{} {
|
|
if cfg == nil || len(cfg.Apps.AppGroups) == 0 {
|
|
return nil
|
|
}
|
|
|
|
profiles := ProfilesFromConfigNodes(cfg)
|
|
if len(profiles) == 0 {
|
|
return nil
|
|
}
|
|
plans := buildGroupOutboundPlans(profiles)
|
|
if len(plans) == 0 {
|
|
return nil
|
|
}
|
|
|
|
groupOutbounds := make(map[string]string, len(plans))
|
|
for _, plan := range plans {
|
|
groupOutbounds[plan.name] = "group-" + plan.base
|
|
}
|
|
|
|
alwaysDirect := map[string]struct{}{}
|
|
for _, packageName := range cfg.Routing.AlwaysDirectApps {
|
|
packageName = strings.TrimSpace(packageName)
|
|
if packageName != "" {
|
|
alwaysDirect[packageName] = struct{}{}
|
|
}
|
|
}
|
|
packagesByOutbound := map[string][]string{}
|
|
for packageName, groupName := range cfg.Apps.AppGroups {
|
|
packageName = strings.TrimSpace(packageName)
|
|
groupName = strings.TrimSpace(groupName)
|
|
if packageName == "" || groupName == "" {
|
|
continue
|
|
}
|
|
if _, blocked := alwaysDirect[packageName]; blocked {
|
|
continue
|
|
}
|
|
outbound := groupOutbounds[groupName]
|
|
if outbound == "" {
|
|
continue
|
|
}
|
|
packagesByOutbound[outbound] = append(packagesByOutbound[outbound], packageName)
|
|
}
|
|
|
|
outbounds := make([]string, 0, len(packagesByOutbound))
|
|
for outbound := range packagesByOutbound {
|
|
outbounds = append(outbounds, outbound)
|
|
}
|
|
sort.Strings(outbounds)
|
|
|
|
rules := make([]map[string]interface{}, 0, len(outbounds))
|
|
for _, outbound := range outbounds {
|
|
packages := packagesByOutbound[outbound]
|
|
sort.Strings(packages)
|
|
rules = append(rules, map[string]interface{}{
|
|
"package_name": packages,
|
|
"action": "route",
|
|
"outbound": outbound,
|
|
})
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func uniqueSortedPackages(values []string, excludeSets ...map[string]struct{}) []string {
|
|
seen := map[string]struct{}{}
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
excluded := false
|
|
for _, excludeSet := range excludeSets {
|
|
if _, ok := excludeSet[value]; ok {
|
|
excluded = true
|
|
break
|
|
}
|
|
}
|
|
if excluded {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
}
|
|
result := make([]string, 0, len(seen))
|
|
for value := range seen {
|
|
result = append(result, value)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func splitCSV(value string) []string {
|
|
var result []string
|
|
for _, item := range strings.Split(value, ",") {
|
|
item = strings.TrimSpace(item)
|
|
if item != "" {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseBool(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func valueOrDefault(value string, fallback string) string {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func buildRuleSets(cfg *Config) []map[string]interface{} {
|
|
var sets []map[string]interface{}
|
|
|
|
if cfg.Routing.BypassChina {
|
|
sets = append(sets,
|
|
remoteRuleSet("geoip-cn", "SagerNet/sing-geoip", "direct"),
|
|
remoteRuleSet("geosite-cn", "SagerNet/sing-geosite", "direct"),
|
|
)
|
|
}
|
|
|
|
if cfg.Routing.BlockAds {
|
|
sets = append(sets, remoteRuleSet("geosite-category-ads-all", "SagerNet/sing-geosite", "direct"))
|
|
}
|
|
|
|
return sets
|
|
}
|
|
|
|
func remoteRuleSet(tag string, repository string, detour string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "remote",
|
|
"tag": tag,
|
|
"format": "binary",
|
|
"url": fmt.Sprintf("https://raw.githubusercontent.com/%s/rule-set/%s.srs", repository, tag),
|
|
"download_detour": detour,
|
|
"update_interval": "24h",
|
|
}
|
|
}
|
|
|
|
func russianDomainSuffixes() []string {
|
|
return []string{".ru", ".su", ".xn--p1ai"}
|
|
}
|
|
|
|
func russianServiceDirectCIDRs() []string {
|
|
var cidrs []string
|
|
for _, line := range strings.Split(russianServiceDirectCIDRsText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
cidrs = append(cidrs, line)
|
|
}
|
|
return cidrs
|
|
}
|
|
|
|
func ensureExperimental(sbCfg map[string]interface{}) map[string]interface{} {
|
|
if existing, ok := sbCfg["experimental"].(map[string]interface{}); ok {
|
|
return existing
|
|
}
|
|
experimental := map[string]interface{}{}
|
|
sbCfg["experimental"] = experimental
|
|
return experimental
|
|
}
|
|
|
|
func splitRuleInputs(values []string) (domains []string, cidrs []string) {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(value, "/") {
|
|
cidrs = append(cidrs, value)
|
|
} else {
|
|
domains = append(domains, value)
|
|
}
|
|
}
|
|
return domains, cidrs
|
|
}
|