Run official Xray-core on nodes with full Reality, TLS, and routing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user