// Package xraycfg builds Xray-core (XTLS) JSON configs from panel inbound payloads. // Spec: https://github.com/XTLS/Xray-core package xraycfg import ( "encoding/json" "fmt" "strings" ) // Inbound is a panel→node inbound description used to generate Xray inbound objects. type Inbound struct { ID string Tag string Protocol string Port int Listen string Network string Security string Remark string Path string Host string SNI string Fingerprint string Flow string ALPN string RealityDest string RealityServerNames string RealityPrivateKey string RealityPublicKey string RealityShortID string RealityShortIDs string // comma-separated; overrides RealityShortID when set RealitySpiderX string RealityXver int SSMethod string Password string FallbackDest string // simple fallback, e.g. 127.0.0.1:80 FallbacksJSON string // optional raw JSON array of fallback objects TLSCertPEM string TLSKeyPEM string Sniffing bool SniffingRouteOnly bool Clients []Client } type Client struct { ID string UUID string Email string Username string Flow string // optional per-client override } func (c Client) EmailOrUser() string { if c.Email != "" { return c.Email } if c.Username != "" { return c.Username } if c.UUID != "" { return c.UUID } return c.ID } // Build produces a full Xray-core config.json object. func Build(inbounds []Inbound, certDir string) (map[string]any, int, error) { var xIn []map[string]any for _, in := range inbounds { obj, ok, err := buildInbound(in, certDir) if err != nil { return nil, 0, err } if ok { xIn = append(xIn, obj) } } if xIn == nil { xIn = []map[string]any{} } cfg := map[string]any{ "log": map[string]any{ "loglevel": "warning", "access": "none", "error": "", }, "dns": map[string]any{ "servers": []any{ "1.1.1.1", "1.0.0.1", "8.8.8.8", "localhost", }, "queryStrategy": "UseIP", }, "inbounds": xIn, "outbounds": []map[string]any{ { "tag": "direct", "protocol": "freedom", "settings": map[string]any{ "domainStrategy": "UseIP", }, }, { "tag": "block", "protocol": "blackhole", "settings": map[string]any{ "response": map[string]any{"type": "http"}, }, }, }, "routing": map[string]any{ "domainStrategy": "AsIs", "rules": []map[string]any{ { "type": "field", "protocol": []string{"bittorrent"}, "outboundTag": "block", }, { "type": "field", "ip": []string{"geoip:private"}, "outboundTag": "block", }, }, }, "policy": map[string]any{ "levels": map[string]any{ "0": map[string]any{ "handshake": 4, "connIdle": 300, "uplinkOnly": 2, "downlinkOnly": 5, "statsUserUplink": true, "statsUserDownlink": true, }, }, "system": map[string]any{ "statsInboundUplink": true, "statsInboundDownlink": true, "statsOutboundUplink": true, "statsOutboundDownlink": true, }, }, "stats": map[string]any{}, } return cfg, len(xIn), nil } func buildInbound(in Inbound, certDir string) (map[string]any, bool, error) { proto := strings.ToLower(strings.TrimSpace(in.Protocol)) switch proto { case "vless", "vmess", "trojan", "shadowsocks": default: return nil, false, nil } tag := in.Tag if tag == "" { tag = proto } listen := in.Listen if listen == "" { listen = "0.0.0.0" } if in.Port <= 0 { return nil, false, nil } network := strings.ToLower(or(in.Network, "tcp")) security := strings.ToLower(or(in.Security, "none")) settings, err := protocolSettings(proto, in) if err != nil { return nil, false, err } stream, err := streamSettings(in, network, security, certDir) if err != nil { return nil, false, err } obj := map[string]any{ "tag": tag, "listen": listen, "port": in.Port, "protocol": proto, "settings": settings, "streamSettings": stream, "sniffing": map[string]any{ "enabled": in.Sniffing, "destOverride": []string{"http", "tls", "quic"}, "routeOnly": in.SniffingRouteOnly, }, } return obj, true, nil } func protocolSettings(proto string, in Inbound) (map[string]any, error) { switch proto { case "vless": var list []map[string]any for _, c := range in.Clients { item := map[string]any{ "id": c.UUID, "email": c.EmailOrUser(), } flow := c.Flow if flow == "" { flow = in.Flow } if flow == "" && in.Security == "reality" && or(in.Network, "tcp") == "tcp" { flow = "xtls-rprx-vision" } if flow != "" { item["flow"] = flow } list = append(list, item) } if list == nil { list = []map[string]any{} } settings := map[string]any{ "clients": list, "decryption": "none", } if fb := parseFallbacks(in); len(fb) > 0 { settings["fallbacks"] = fb } return settings, nil case "vmess": var list []map[string]any for _, c := range in.Clients { list = append(list, map[string]any{ "id": c.UUID, "email": c.EmailOrUser(), "alterId": 0, }) } if list == nil { list = []map[string]any{} } return map[string]any{"clients": list}, nil case "trojan": var list []map[string]any for _, c := range in.Clients { pass := c.UUID if in.Password != "" { pass = in.Password } list = append(list, map[string]any{ "password": pass, "email": c.EmailOrUser(), }) } if list == nil { list = []map[string]any{} } settings := map[string]any{"clients": list} if fb := parseFallbacks(in); len(fb) > 0 { settings["fallbacks"] = fb } return settings, nil case "shadowsocks": method := or(in.SSMethod, "aes-128-gcm") var list []map[string]any for _, c := range in.Clients { list = append(list, map[string]any{ "password": c.UUID, "email": c.EmailOrUser(), "method": method, }) } if list == nil { list = []map[string]any{} } return map[string]any{ "method": method, "password": or(in.Password, "panel-ss"), "clients": list, "network": "tcp,udp", }, nil } return nil, fmt.Errorf("unsupported protocol %s", proto) } func streamSettings(in Inbound, network, security, certDir string) (map[string]any, error) { stream := map[string]any{ "network": network, "security": security, } switch network { case "ws": ws := map[string]any{} if in.Path != "" { ws["path"] = in.Path } if in.Host != "" { ws["headers"] = map[string]any{"Host": in.Host} } stream["wsSettings"] = ws case "grpc", "gun": grpc := map[string]any{ "multiMode": false, } if in.Path != "" { grpc["serviceName"] = in.Path } stream["grpcSettings"] = grpc case "httpupgrade": hu := map[string]any{} if in.Path != "" { hu["path"] = in.Path } if in.Host != "" { hu["host"] = in.Host } stream["httpupgradeSettings"] = hu case "xhttp", "splithttp": xh := map[string]any{ "mode": "auto", } if in.Path != "" { xh["path"] = in.Path } if in.Host != "" { xh["host"] = in.Host } stream["xhttpSettings"] = xh case "tcp": // optional HTTP camouflage when path/host set without ws if in.Path != "" || in.Host != "" { stream["tcpSettings"] = map[string]any{ "header": map[string]any{ "type": "http", "request": map[string]any{ "path": splitPaths(in.Path), "headers": map[string]any{ "Host": splitCSV(in.Host), }, }, }, } } case "kcp", "mkcp": stream["kcpSettings"] = map[string]any{ "mtu": 1350, "tti": 50, "uplinkCapacity": 5, "downlinkCapacity": 20, "congestion": false, "header": map[string]any{"type": "none"}, } } switch security { case "tls": tls := map[string]any{ "rejectUnknownSni": false, "minVersion": "1.2", } if in.SNI != "" { tls["serverName"] = in.SNI } if in.ALPN != "" { tls["alpn"] = splitCSV(in.ALPN) } if in.Fingerprint != "" { tls["fingerprint"] = in.Fingerprint } if in.TLSCertPEM != "" && in.TLSKeyPEM != "" && certDir != "" { id := in.ID if id == "" { id = sanitizeTag(in.Tag) } certFile := certDir + "/" + id + ".crt" keyFile := certDir + "/" + id + ".key" tls["certificates"] = []map[string]any{{ "certificateFile": certFile, "keyFile": keyFile, }} } stream["tlsSettings"] = tls case "reality": if in.RealityPrivateKey == "" { return nil, fmt.Errorf("inbound %s: reality private key required", in.Tag) } dest := or(in.RealityDest, "www.microsoft.com:443") names := splitCSV(in.RealityServerNames) if len(names) == 0 { names = []string{"www.microsoft.com"} } shortIDs := shortIDList(in) spiderX := in.RealitySpiderX if spiderX == "" { spiderX = "/" } reality := map[string]any{ "show": false, "dest": dest, "xver": in.RealityXver, "serverNames": names, "privateKey": in.RealityPrivateKey, "shortIds": shortIDs, "spiderX": spiderX, } stream["realitySettings"] = reality } stream["sockopt"] = map[string]any{ "tcpFastOpen": false, "tcpKeepAliveIdle": 100, "domainStrategy": "UseIP", } return stream, nil } func parseFallbacks(in Inbound) []map[string]any { if strings.TrimSpace(in.FallbacksJSON) != "" { var raw []map[string]any if err := json.Unmarshal([]byte(in.FallbacksJSON), &raw); err == nil && len(raw) > 0 { return raw } } if dest := strings.TrimSpace(in.FallbackDest); dest != "" { return []map[string]any{{"dest": dest}} } return nil } func shortIDList(in Inbound) []string { raw := in.RealityShortIDs if strings.TrimSpace(raw) == "" { raw = in.RealityShortID } ids := splitCSV(raw) if len(ids) == 0 { return []string{""} } return ids } func splitCSV(s string) []string { parts := strings.Split(s, ",") var out []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func splitPaths(path string) []string { if path == "" { return []string{"/"} } parts := splitCSV(path) if len(parts) == 0 { return []string{"/"} } return parts } func or(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v } func sanitizeTag(tag string) string { tag = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { return r } return '_' }, tag) if tag == "" { return "inbound" } return tag } // FromMap converts a loose JSON map (agent payload item) into Inbound. func FromMap(m map[string]any) Inbound { in := Inbound{ ID: str(m, "id"), Tag: str(m, "tag"), Protocol: str(m, "protocol"), Port: asInt(m["port"]), Listen: str(m, "listen"), Network: str(m, "network"), Security: str(m, "security"), Remark: str(m, "remark"), Path: str(m, "path"), Host: str(m, "host"), SNI: str(m, "sni"), Fingerprint: str(m, "fingerprint"), Flow: str(m, "flow"), ALPN: str(m, "alpn"), RealityDest: str(m, "reality_dest"), RealityServerNames: str(m, "reality_server_names"), RealityPrivateKey: str(m, "reality_private_key"), RealityPublicKey: str(m, "reality_public_key"), RealityShortID: str(m, "reality_short_id"), RealityShortIDs: str(m, "reality_short_ids"), RealitySpiderX: str(m, "spider_x"), RealityXver: asInt(m["reality_xver"]), SSMethod: str(m, "ss_method"), Password: str(m, "password"), FallbackDest: str(m, "fallback_dest"), FallbacksJSON: str(m, "fallbacks_json"), TLSCertPEM: str(m, "tls_cert_pem"), TLSKeyPEM: str(m, "tls_key_pem"), Sniffing: asBoolDefault(m["sniffing"], true), SniffingRouteOnly: asBoolDefault(m["sniffing_route_only"], false), } if arr, ok := m["clients"].([]any); ok { for _, item := range arr { cm, ok := item.(map[string]any) if !ok { continue } uid := str(cm, "uuid") if uid == "" { continue } in.Clients = append(in.Clients, Client{ ID: str(cm, "id"), UUID: uid, Email: str(cm, "email"), Username: str(cm, "username"), Flow: str(cm, "flow"), }) } } return in } func str(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { return "" } switch t := v.(type) { case string: return t default: return fmt.Sprint(t) } } func asInt(v any) int { switch t := v.(type) { case float64: return int(t) case int: return t case int64: return int(t) case json.Number: n, _ := t.Int64() return int(n) case string: var n int _, _ = fmt.Sscanf(t, "%d", &n) return n default: return 0 } } func asBoolDefault(v any, def bool) bool { if v == nil { return def } switch t := v.(type) { case bool: return t case string: return t == "1" || strings.EqualFold(t, "true") || t == "on" default: return def } }