694 lines
17 KiB
Go
694 lines
17 KiB
Go
package xray
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/cedar2025/xboard-node/internal/config"
|
|
"github.com/cedar2025/xboard-node/internal/kernel"
|
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
|
"github.com/cedar2025/xboard-node/internal/panel"
|
|
)
|
|
|
|
// M is a shorthand for building JSON-like maps
|
|
type M = map[string]interface{}
|
|
|
|
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
var outbounds []M
|
|
tags := make(map[string]bool)
|
|
|
|
// Panel-defined custom outbounds (structured, converted to Xray native)
|
|
for _, co := range nc.CustomOutbounds {
|
|
outbounds = append(outbounds, outboundConfigToXray(co))
|
|
tags[strings.ToLower(co.Tag)] = true
|
|
}
|
|
|
|
// Static outbounds from local config file
|
|
for _, co := range kcfg.CustomOutbound {
|
|
tag, _ := co["tag"].(string)
|
|
if tag != "" {
|
|
tags[strings.ToLower(tag)] = true
|
|
}
|
|
outbounds = append(outbounds, M(co))
|
|
}
|
|
|
|
// Add default outbounds only if not already defined (Issue #1: Panel priority)
|
|
if !tags["direct"] {
|
|
outbounds = append([]M{{"protocol": "freedom", "tag": "direct"}}, outbounds...)
|
|
}
|
|
if !tags["block"] {
|
|
// block is often added after direct but before others for safety
|
|
outbounds = append(outbounds, M{"protocol": "blackhole", "tag": "block"})
|
|
}
|
|
|
|
cfg := M{
|
|
"log": M{
|
|
"loglevel": xrayLogLevel(kcfg.LogLevel),
|
|
"error": "",
|
|
"access": "",
|
|
},
|
|
"stats": M{},
|
|
"policy": M{
|
|
"levels": M{
|
|
"0": M{
|
|
"statsUserUplink": true,
|
|
"statsUserDownlink": true,
|
|
},
|
|
},
|
|
"system": M{
|
|
"statsInboundUplink": true,
|
|
"statsInboundDownlink": true,
|
|
"statsOutboundUplink": true,
|
|
"statsOutboundDownlink": true,
|
|
},
|
|
},
|
|
"outbounds": outbounds,
|
|
}
|
|
|
|
inbound := buildInbound(nc, users, certFile, keyFile)
|
|
if inbound != nil {
|
|
cfg["inbounds"] = []M{inbound}
|
|
} else {
|
|
nlog.Core().Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
|
|
"protocol", nc.Protocol,
|
|
"supported", "vmess, vless, trojan, shadowsocks, hysteria, socks, http")
|
|
}
|
|
|
|
// Merge panel routes and static config routes
|
|
cfg["routing"] = buildRouting(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
|
|
|
mergeCustomXray(cfg, kcfg)
|
|
return cfg
|
|
}
|
|
|
|
// outboundConfigToXray converts a structured OutboundConfig (from the panel)
|
|
// into an Xray outbound object. Xray uses a nested layout where protocol-
|
|
// specific fields go inside a "settings" key, and chain proxying uses "proxySettings".
|
|
func outboundConfigToXray(oc panel.OutboundConfig) M {
|
|
m := M{
|
|
"protocol": oc.Protocol,
|
|
"tag": oc.Tag,
|
|
}
|
|
if len(oc.Settings) > 0 {
|
|
m["settings"] = oc.Settings
|
|
}
|
|
if oc.ProxyTag != "" {
|
|
m["proxySettings"] = M{"tag": oc.ProxyTag}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func mergeRouteList(a, b []map[string]any) []map[string]any {
|
|
res := make([]map[string]any, 0, len(a)+len(b))
|
|
res = append(res, a...)
|
|
res = append(res, b...)
|
|
return res
|
|
}
|
|
|
|
// mergeCustomXray deep-merges a custom Xray config file into the generated config.
|
|
// Merge strategy (compatible with V2bX/XrayR custom DNS etc.):
|
|
// - dns: custom replaces auto-generated
|
|
// - outbounds: custom entries appended
|
|
// - routing.rules: custom rules prepended (matched first)
|
|
// - policy, api: custom deep-merges
|
|
// - inbounds: NOT merged (panel-managed, authoritative)
|
|
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
|
|
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
|
if err != nil {
|
|
nlog.Core().Error("failed to load custom xray config", "error", err)
|
|
return
|
|
}
|
|
if custom == nil {
|
|
return
|
|
}
|
|
|
|
// dns — custom replaces (same as V2bX DnsConfigPath)
|
|
if v, ok := custom["dns"]; ok {
|
|
cfg["dns"] = v
|
|
}
|
|
|
|
// outbounds — append custom entries
|
|
if v, ok := custom["outbounds"]; ok {
|
|
if existing, ok := cfg["outbounds"].([]M); ok {
|
|
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
|
|
}
|
|
}
|
|
|
|
// routing — merge sub-fields
|
|
if customRouting, ok := custom["routing"]; ok {
|
|
if customRoutingMap, ok := customRouting.(map[string]any); ok {
|
|
mergeCustomXrayRouting(cfg, customRoutingMap)
|
|
}
|
|
}
|
|
|
|
// other top-level keys (policy, api, transport, etc.) — custom overrides,
|
|
// but we protect auto-generated inbounds, stats, log, routing, outbounds
|
|
protected := map[string]bool{
|
|
"inbounds": true, "outbounds": true, "routing": true,
|
|
"dns": true, "log": true, "stats": true, "policy": true,
|
|
}
|
|
for k, v := range custom {
|
|
if !protected[k] {
|
|
cfg[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func mergeCustomXrayRouting(cfg M, customRouting map[string]any) {
|
|
routing, ok := cfg["routing"].(M)
|
|
if !ok {
|
|
routing = M{}
|
|
cfg["routing"] = routing
|
|
}
|
|
|
|
// rules — custom rules prepended (so they match before panel rules)
|
|
if v, ok := customRouting["rules"]; ok {
|
|
if existing, ok := routing["rules"].([]M); ok {
|
|
routing["rules"] = kernel.MergePrependList(existing, v)
|
|
}
|
|
}
|
|
|
|
// domainStrategy, balancers, etc. — custom overrides
|
|
for k, v := range customRouting {
|
|
if k == "rules" {
|
|
continue
|
|
}
|
|
routing[k] = v
|
|
}
|
|
}
|
|
|
|
func xrayLogLevel(singboxLevel string) string {
|
|
switch singboxLevel {
|
|
case "trace", "debug":
|
|
return "debug"
|
|
case "info":
|
|
return "info"
|
|
case "warn":
|
|
return "warning"
|
|
case "error":
|
|
return "error"
|
|
case "fatal", "panic":
|
|
return "error"
|
|
default:
|
|
return "warning"
|
|
}
|
|
}
|
|
|
|
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
listenAddr := "::"
|
|
if nc.ListenIP != "" {
|
|
listenAddr = nc.ListenIP
|
|
}
|
|
base := M{
|
|
"tag": nc.Protocol + "-in",
|
|
"listen": listenAddr,
|
|
"port": nc.ServerPort,
|
|
"protocol": nc.Protocol,
|
|
"streamSettings": M{
|
|
"sockopt": M{
|
|
"reusePort": true,
|
|
},
|
|
},
|
|
}
|
|
|
|
switch nc.Protocol {
|
|
case "vmess":
|
|
return buildVMess(base, nc, users, certFile, keyFile)
|
|
case "vless":
|
|
return buildVLESS(base, nc, users, certFile, keyFile)
|
|
case "trojan":
|
|
return buildTrojan(base, nc, users, certFile, keyFile)
|
|
case "shadowsocks":
|
|
return buildShadowsocks(base, nc, users)
|
|
case "socks":
|
|
return buildSocks(base, users)
|
|
case "http":
|
|
return buildHTTP(base, nc, users, certFile, keyFile)
|
|
case "hysteria":
|
|
return buildHysteria(base, nc, users, certFile, keyFile)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// userEmail returns the stats-tracking email for a user.
|
|
// Format: "user@<id>" so we can parse back the user ID from stats counters.
|
|
func userEmail(userID int) string {
|
|
return fmt.Sprintf("user@%d", userID)
|
|
}
|
|
|
|
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
clients := make([]M, 0, len(users))
|
|
for _, u := range users {
|
|
clients = append(clients, M{
|
|
"id": u.UUID,
|
|
"alterId": 0,
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{"clients": clients}
|
|
|
|
applyStreamSettings(base, nc, certFile, keyFile)
|
|
return base
|
|
}
|
|
|
|
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
clients := make([]M, 0, len(users))
|
|
for _, u := range users {
|
|
client := M{
|
|
"id": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
}
|
|
if nc.Flow != "" {
|
|
client["flow"] = nc.Flow
|
|
}
|
|
clients = append(clients, client)
|
|
}
|
|
decryption := "none"
|
|
if nc.Decryption != "" {
|
|
decryption = nc.Decryption
|
|
}
|
|
base["settings"] = M{
|
|
"clients": clients,
|
|
"decryption": decryption,
|
|
}
|
|
|
|
applyStreamSettings(base, nc, certFile, keyFile)
|
|
return base
|
|
}
|
|
|
|
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
clients := make([]M, len(users))
|
|
for i := range users {
|
|
u := &users[i]
|
|
clients[i] = M{
|
|
"password": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
}
|
|
}
|
|
base["settings"] = M{"clients": clients}
|
|
|
|
applyStreamSettings(base, nc, certFile, keyFile)
|
|
|
|
// Trojan requires TLS or Reality to be enabled.
|
|
// If the panel didn't explicitly set TLS=1 or TLS=2, but we have certs,
|
|
// we should enable a default TLS config to ensure the inbound can start.
|
|
ss, _ := base["streamSettings"].(M)
|
|
if security, ok := ss["security"].(string); !ok || (security != "tls" && security != "reality") {
|
|
nc.TLS = 1 // Force internal state to trigger TLS build in applyStreamSettings
|
|
applyStreamSettings(base, nc, certFile, keyFile)
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
type ss2022Config struct {
|
|
method string
|
|
size int
|
|
}
|
|
|
|
var ss2022Methods = map[string]ss2022Config{
|
|
"2022-blake3-aes-128-gcm": {"2022-blake3-aes-128-gcm", 16},
|
|
"2022-blake3-aes-256-gcm": {"2022-blake3-aes-256-gcm", 32},
|
|
"2022-blake3-chacha20-poly1305": {"2022-blake3-chacha20-poly1305", 32},
|
|
}
|
|
|
|
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
|
ss2022, isSS2022 := ss2022Methods[nc.Cipher]
|
|
|
|
clients := make([]M, 0, len(users))
|
|
|
|
if isSS2022 {
|
|
// SS2022: server key at top level, per-user key must be Base64 of fixed-length raw bytes.
|
|
// Only blake3-aes-* supports multi-user in Xray; chacha20 variant is single-user only.
|
|
rawBuf := make([]byte, ss2022.size)
|
|
for i := range users {
|
|
u := &users[i]
|
|
for j := range rawBuf {
|
|
rawBuf[j] = 0
|
|
}
|
|
copy(rawBuf, u.UUID)
|
|
clients = append(clients, M{
|
|
"password": base64.StdEncoding.EncodeToString(rawBuf),
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{
|
|
"method": nc.Cipher,
|
|
"password": nc.ServerKey,
|
|
"clients": clients,
|
|
"network": "tcp,udp",
|
|
}
|
|
} else {
|
|
// Traditional ciphers: multi-user via clients array.
|
|
// Each entry must carry its own "method" field for Xray to parse correctly.
|
|
for i := range users {
|
|
u := &users[i]
|
|
clients = append(clients, M{
|
|
"method": nc.Cipher,
|
|
"password": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{
|
|
"method": nc.Cipher,
|
|
"clients": clients,
|
|
"network": "tcp,udp",
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
func buildSocks(base M, users []panel.User) M {
|
|
base["protocol"] = "socks"
|
|
accounts := make([]M, 0, len(users))
|
|
for _, u := range users {
|
|
accounts = append(accounts, M{
|
|
"user": u.UUID,
|
|
"pass": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{
|
|
"auth": "password",
|
|
"accounts": accounts,
|
|
"udp": true,
|
|
}
|
|
return base
|
|
}
|
|
|
|
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
base["protocol"] = "http"
|
|
accounts := make([]M, 0, len(users))
|
|
for _, u := range users {
|
|
accounts = append(accounts, M{
|
|
"user": u.UUID,
|
|
"pass": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{
|
|
"accounts": accounts,
|
|
}
|
|
|
|
if nc.TLS == 1 {
|
|
applyStreamSettings(base, nc, certFile, keyFile)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// buildHysteria creates a Hysteria v2 inbound for xray-core.
|
|
func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
|
if nc.Version != 2 {
|
|
nlog.Core().Warn("xray: only supports hysteria v2, skipping v1 inbound")
|
|
return nil
|
|
}
|
|
|
|
clients := make([]M, 0, len(users))
|
|
for _, u := range users {
|
|
clients = append(clients, M{
|
|
"auth": u.UUID,
|
|
"email": userEmail(u.ID),
|
|
})
|
|
}
|
|
base["settings"] = M{"clients": clients}
|
|
|
|
ss := M{
|
|
"network": "hysteria",
|
|
"hysteriaSettings": M{
|
|
"version": nc.Version,
|
|
"udpIdleTimeout": 60,
|
|
},
|
|
}
|
|
|
|
if nc.UpMbps > 0 || nc.DownMbps > 0 {
|
|
quicParams := M{}
|
|
if nc.UpMbps > 0 {
|
|
quicParams["brutalUp"] = fmt.Sprintf("%d mbps", nc.UpMbps)
|
|
}
|
|
if nc.DownMbps > 0 {
|
|
quicParams["brutalDown"] = fmt.Sprintf("%d mbps", nc.DownMbps)
|
|
}
|
|
ss["finalMask"] = M{
|
|
"quicParams": quicParams,
|
|
}
|
|
}
|
|
|
|
if certFile != "" && keyFile != "" {
|
|
ss["security"] = "tls"
|
|
ss["tlsSettings"] = M{
|
|
"certificates": []M{
|
|
{
|
|
"certificateFile": certFile,
|
|
"keyFile": keyFile,
|
|
},
|
|
},
|
|
"alpn": []string{"h3"},
|
|
}
|
|
} else {
|
|
nlog.Core().Warn("hysteria requires TLS certificate files; configure cert_mode (self, file, http, dns, or content)")
|
|
}
|
|
|
|
if nc.GetProxyProtocol() {
|
|
ss["sockopt"] = M{"acceptProxyProtocol": true}
|
|
}
|
|
|
|
base["streamSettings"] = ss
|
|
return base
|
|
}
|
|
|
|
func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) {
|
|
ss := M{}
|
|
|
|
// Network / transport
|
|
network := nc.Network
|
|
if network == "" {
|
|
network = "tcp"
|
|
}
|
|
ss["network"] = network
|
|
|
|
switch network {
|
|
case "ws":
|
|
wsSettings := M{}
|
|
if nc.NetworkSettings != nil {
|
|
if v, ok := nc.NetworkSettings["path"]; ok {
|
|
wsSettings["path"] = v
|
|
}
|
|
headers := M{}
|
|
if v, ok := nc.NetworkSettings["headers"]; ok {
|
|
if headersMap, ok := v.(map[string]interface{}); ok {
|
|
for k, val := range headersMap {
|
|
headers[k] = val
|
|
}
|
|
}
|
|
}
|
|
if v, ok := nc.NetworkSettings["host"]; ok {
|
|
headers["Host"] = v
|
|
}
|
|
if len(headers) > 0 {
|
|
wsSettings["headers"] = headers
|
|
}
|
|
}
|
|
ss["wsSettings"] = wsSettings
|
|
|
|
case "grpc":
|
|
grpcSettings := M{}
|
|
if nc.NetworkSettings != nil {
|
|
if v, ok := nc.NetworkSettings["serviceName"]; ok {
|
|
grpcSettings["serviceName"] = v
|
|
}
|
|
}
|
|
ss["grpcSettings"] = grpcSettings
|
|
|
|
case "httpupgrade":
|
|
huSettings := M{}
|
|
if nc.NetworkSettings != nil {
|
|
if v, ok := nc.NetworkSettings["path"]; ok {
|
|
huSettings["path"] = v
|
|
}
|
|
if v, ok := nc.NetworkSettings["host"]; ok {
|
|
huSettings["host"] = v
|
|
}
|
|
}
|
|
ss["httpupgradeSettings"] = huSettings
|
|
|
|
case "h2", "http":
|
|
ss["network"] = "h2"
|
|
h2Settings := M{}
|
|
if nc.NetworkSettings != nil {
|
|
if v, ok := nc.NetworkSettings["path"]; ok {
|
|
h2Settings["path"] = v
|
|
}
|
|
if v, ok := nc.NetworkSettings["host"]; ok {
|
|
h2Settings["host"] = []interface{}{v}
|
|
}
|
|
}
|
|
ss["httpSettings"] = h2Settings
|
|
|
|
case "tcp":
|
|
// default, no extra settings
|
|
}
|
|
|
|
// TLS
|
|
if nc.TLS == 1 {
|
|
tlsSettings := M{}
|
|
serverName := nc.ServerName
|
|
if serverName == "" && nc.Host != "" {
|
|
serverName = nc.Host
|
|
}
|
|
if serverName != "" {
|
|
tlsSettings["serverName"] = serverName
|
|
}
|
|
if nc.TLSSettings != nil {
|
|
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
|
tlsSettings["serverName"] = sn
|
|
}
|
|
}
|
|
if certFile != "" && keyFile != "" {
|
|
tlsSettings["certificates"] = []M{
|
|
{
|
|
"certificateFile": certFile,
|
|
"keyFile": keyFile,
|
|
},
|
|
}
|
|
} else {
|
|
// Fallback placeholder for auto-TLS environments.
|
|
// Xray allows empty certificates array in more cases than sing-box,
|
|
// but providing a placeholder helps documentation.
|
|
}
|
|
ss["security"] = "tls"
|
|
ss["tlsSettings"] = tlsSettings
|
|
} else if nc.TLS == 2 {
|
|
ss["security"] = "reality"
|
|
ss["realitySettings"] = buildRealitySettings(nc)
|
|
}
|
|
|
|
// Proxy Protocol
|
|
if nc.GetProxyProtocol() {
|
|
sockopt, ok := base["streamSettings"].(M)["sockopt"].(M)
|
|
if !ok {
|
|
sockopt = M{}
|
|
}
|
|
sockopt["acceptProxyProtocol"] = true
|
|
ss["sockopt"] = sockopt
|
|
}
|
|
|
|
base["streamSettings"] = ss
|
|
}
|
|
|
|
func buildRealitySettings(nc *panel.NodeConfig) M {
|
|
reality := M{"show": false}
|
|
|
|
if nc.TLSSettings == nil {
|
|
return reality
|
|
}
|
|
|
|
if pk, ok := nc.TLSSettings["private_key"]; ok {
|
|
reality["privateKey"] = pk
|
|
}
|
|
if sid, ok := nc.TLSSettings["short_id"]; ok {
|
|
switch v := sid.(type) {
|
|
case string:
|
|
reality["shortIds"] = []string{v}
|
|
case []interface{}:
|
|
ids := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
ids = append(ids, fmt.Sprintf("%v", item))
|
|
}
|
|
reality["shortIds"] = ids
|
|
}
|
|
}
|
|
|
|
if dest, ok := nc.TLSSettings["dest"]; ok {
|
|
destStr := fmt.Sprintf("%v", dest)
|
|
reality["dest"] = destStr
|
|
}
|
|
if sn, ok := nc.TLSSettings["server_name"]; ok {
|
|
reality["serverNames"] = []string{fmt.Sprintf("%v", sn)}
|
|
if _, exists := reality["dest"]; !exists {
|
|
reality["dest"] = fmt.Sprintf("%v:443", sn)
|
|
}
|
|
}
|
|
|
|
return reality
|
|
}
|
|
|
|
func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
|
|
var xrayRules []M
|
|
|
|
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
|
for _, cr := range customRules {
|
|
xrayRules = append(xrayRules, M(cr))
|
|
}
|
|
|
|
xrayRules = append(xrayRules, M{
|
|
"type": "field",
|
|
"ip": []string{
|
|
"10.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"172.16.0.0/12",
|
|
"192.0.0.0/24",
|
|
"192.168.0.0/16",
|
|
"198.18.0.0/15",
|
|
"fc00::/7",
|
|
"fe80::/10",
|
|
"::1/128",
|
|
},
|
|
"outboundTag": "block",
|
|
})
|
|
|
|
for _, rule := range rules {
|
|
if len(rule.Match) == 0 {
|
|
continue
|
|
}
|
|
|
|
var ips, domains []string
|
|
for _, m := range rule.Match {
|
|
m = strings.TrimSpace(m)
|
|
if m == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(m, "geoip:") {
|
|
ips = append(ips, m) // "geoip:cn" → ip field, passed as-is
|
|
} else if strings.HasPrefix(m, "geosite:") {
|
|
domains = append(domains, m) // "geosite:google" → domain field, passed as-is
|
|
} else if strings.Contains(m, "/") {
|
|
ips = append(ips, m)
|
|
} else {
|
|
m = strings.TrimPrefix(m, "*.")
|
|
domains = append(domains, "domain:"+m)
|
|
}
|
|
}
|
|
|
|
outbound := "block"
|
|
if rule.Action == "direct" {
|
|
outbound = "direct"
|
|
} else if rule.Action == "proxy" && rule.ActionValue != "" {
|
|
outbound = rule.ActionValue
|
|
}
|
|
|
|
if len(domains) > 0 {
|
|
xrayRules = append(xrayRules, M{
|
|
"type": "field",
|
|
"domain": domains,
|
|
"outboundTag": outbound,
|
|
})
|
|
}
|
|
if len(ips) > 0 {
|
|
xrayRules = append(xrayRules, M{
|
|
"type": "field",
|
|
"ip": ips,
|
|
"outboundTag": outbound,
|
|
})
|
|
}
|
|
}
|
|
|
|
return M{
|
|
"domainStrategy": "AsIs",
|
|
"rules": xrayRules,
|
|
}
|
|
}
|