Files
panel-vpn/internal/xraycfg/config.go
T

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
}
}