592 lines
13 KiB
Go
592 lines
13 KiB
Go
// 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
|
|
}
|
|
}
|