From 5ff9d0ade0c43ae8b4b6cf5b532d7f63c52414e4 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 4 Feb 2026 20:48:21 +0800 Subject: [PATCH] feat(vless): support multi-region URI schemes and node metadata --- api/user_agents.go | 237 ++++++++++++++++----- internal/xrayconfig/VLESS-TCP-URI.Scheme | 2 +- internal/xrayconfig/VLESS-XHTTP-URI.Scheme | 2 +- internal/xrayconfig/template_tcp.json | 79 +++++++ internal/xrayconfig/template_xhttp.json | 46 ++++ internal/xrayconfig/templates.go | 16 ++ 6 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 internal/xrayconfig/template_tcp.json create mode 100644 internal/xrayconfig/template_xhttp.json diff --git a/api/user_agents.go b/api/user_agents.go index 5fd0c8a..9845dcf 100644 --- a/api/user_agents.go +++ b/api/user_agents.go @@ -4,32 +4,45 @@ import ( "errors" "net/http" "net/url" + "os" "strconv" + "strings" "time" "github.com/gin-gonic/gin" "account/internal/auth" "account/internal/store" + "account/internal/xrayconfig" +) + +const ( + defaultXHTTPPath = "/split" + defaultXHTTPMode = "auto" + defaultXHTTPPort = 443 + defaultTCPPort = 1443 + defaultTLSFP = "chrome" + defaultTCPFlow = "xtls-rprx-vision" ) type vlessNode struct { - Name string `json:"name"` - Address string `json:"address"` - Port int `json:"port,omitempty"` - Users []string `json:"users,omitempty"` - Transport string `json:"transport,omitempty"` - Path string `json:"path,omitempty"` - Mode string `json:"mode,omitempty"` - Security string `json:"security,omitempty"` - Flow string `json:"flow,omitempty"` + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port,omitempty"` + Users []string `json:"users,omitempty"` + Transport string `json:"transport,omitempty"` + Path string `json:"path,omitempty"` + Mode string `json:"mode,omitempty"` + Security string `json:"security,omitempty"` + Flow string `json:"flow,omitempty"` + ServerName string `json:"server_name,omitempty"` + XHTTPPort int `json:"xhttp_port,omitempty"` + TCPPort int `json:"tcp_port,omitempty"` + URISchemeXHTTP string `json:"uri_scheme_xhttp,omitempty"` + URISchemeTCP string `json:"uri_scheme_tcp,omitempty"` } func (h *handler) listAgentNodes(c *gin.Context) { - // For now, valid nodes are derived from the server's public URL. - // We currently assume the server itself exposes a VLESS/XHTTP endpoint. - // In the future, we might retrieve this from the agent registry if agents report their public IPs. - // Get current user ID to use as VLESS UUID userID := auth.GetUserID(c) if userID == "" { @@ -55,7 +68,7 @@ func (h *handler) listAgentNodes(c *gin.Context) { return } - proxyUUID := user.ProxyUUID + proxyUUID := strings.TrimSpace(user.ProxyUUID) if proxyUUID == "" { proxyUUID = user.ID } @@ -68,47 +81,165 @@ func (h *handler) listAgentNodes(c *gin.Context) { return } + hosts := parseProxyNodeHosts(h.publicURL) + if len(hosts) == 0 { + c.JSON(http.StatusOK, []vlessNode{}) + return + } + + xhttpPath := envOrDefault("XRAY_XHTTP_PATH", defaultXHTTPPath) + xhttpMode := envOrDefault("XRAY_XHTTP_MODE", defaultXHTTPMode) + xhttpPort := envIntOrDefault("XRAY_XHTTP_PORT", defaultXHTTPPort) + tcpPort := envIntOrDefault("XRAY_TCP_PORT", defaultTCPPort) + + xhttpScheme := xrayconfig.VLESSXHTTPScheme() + tcpScheme := xrayconfig.VLESSTCPScheme() + users := []string{proxyUUID} - nodes := make([]vlessNode, 0) - - if h.publicURL != "" { - u, err := url.Parse(h.publicURL) - if err == nil { - hostname := u.Hostname() - portStr := u.Port() - port := 443 - if portStr != "" { - if p, err := strconv.Atoi(portStr); err == nil { - port = p - } - } else if u.Scheme == "http" { - port = 80 - } - - // Add "Global Acceleration (XHTTP)" node - nodes = append(nodes, vlessNode{ - Name: "Global Acceleration (XHTTP)", - Address: hostname, - Port: port, // Default port, client will adjust based on transport if needed - Users: users, - Transport: "xhttp", - Path: "/split", - Security: "tls", - Mode: "auto", - }) - - // Add "Global Acceleration (TCP)" node - nodes = append(nodes, vlessNode{ - Name: "Global Acceleration (TCP)", - Address: hostname, - Port: 1443, // Fixed TCP port from template_tcp.json - Users: users, - Transport: "tcp", - Security: "tls", - Flow: "xtls-rprx-vision", - }) - } + nodes := make([]vlessNode, 0, len(hosts)) + for _, host := range hosts { + nodeName := nodeNameForHost(host) + nodes = append(nodes, vlessNode{ + Name: nodeName, + Address: host, + Port: xhttpPort, + Users: users, + Transport: "xhttp", + Path: xhttpPath, + Mode: xhttpMode, + Security: "tls", + Flow: defaultTCPFlow, + ServerName: host, + XHTTPPort: xhttpPort, + TCPPort: tcpPort, + URISchemeXHTTP: renderVLESSURIScheme(xhttpScheme, map[string]string{ + "UUID": proxyUUID, + "DOMAIN": host, + "NODE": host, + "PATH": url.QueryEscape(xhttpPath), + "MODE": url.QueryEscape(xhttpMode), + "SNI": host, + "FP": defaultTLSFP, + "TAG": url.QueryEscape(nodeName), + }), + URISchemeTCP: renderVLESSURIScheme(tcpScheme, map[string]string{ + "UUID": proxyUUID, + "DOMAIN": host, + "NODE": host, + "SNI": host, + "FP": defaultTLSFP, + "FLOW": defaultTCPFlow, + "TAG": url.QueryEscape(nodeName), + }), + }) } c.JSON(http.StatusOK, nodes) } + +func parseProxyNodeHosts(publicURL string) []string { + seen := make(map[string]struct{}) + hosts := make([]string, 0) + + appendHost := func(raw string) { + host := normalizeHost(raw) + if host == "" { + return + } + if _, ok := seen[host]; ok { + return + } + seen[host] = struct{}{} + hosts = append(hosts, host) + } + + if raw := strings.TrimSpace(os.Getenv("XRAY_PROXY_NODES")); raw != "" { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' + }) + for _, field := range fields { + appendHost(field) + } + } + + if len(hosts) == 0 { + appendHost(publicURL) + } + + return hosts +} + +func normalizeHost(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + + if strings.Contains(value, "://") { + u, err := url.Parse(value) + if err == nil { + value = u.Hostname() + } + } + + value = strings.TrimPrefix(value, "https://") + value = strings.TrimPrefix(value, "http://") + if strings.Contains(value, "/") { + parts := strings.SplitN(value, "/", 2) + value = parts[0] + } + if strings.Contains(value, ":") { + host, _, found := strings.Cut(value, ":") + if found { + value = host + } + } + + return strings.TrimSpace(value) +} + +func envOrDefault(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func envIntOrDefault(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func nodeNameForHost(host string) string { + prefix := host + if idx := strings.Index(prefix, "."); idx > 0 { + prefix = prefix[:idx] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = host + } + prefix = strings.ReplaceAll(prefix, "_", "-") + prefix = strings.ToUpper(prefix) + return prefix + "-NODE" +} + +func renderVLESSURIScheme(tpl string, values map[string]string) string { + rendered := strings.TrimSpace(tpl) + if rendered == "" { + return "" + } + + for key, value := range values { + rendered = strings.ReplaceAll(rendered, "${"+key+"}", value) + } + return rendered +} diff --git a/internal/xrayconfig/VLESS-TCP-URI.Scheme b/internal/xrayconfig/VLESS-TCP-URI.Scheme index 2a05c44..76fded5 100644 --- a/internal/xrayconfig/VLESS-TCP-URI.Scheme +++ b/internal/xrayconfig/VLESS-TCP-URI.Scheme @@ -1 +1 @@ -vless://${UUID}@${NODE}.svc.plus:443?encryption=none&type=xhttp&security=tls&host=${NODE}.svc.plus&path=${PATH}&mode=auto&sni=${NODE}.svc.plus&alpn=h2%2Chttp%2F1.1%2Ch3#${TAG} +vless://${UUID}@${DOMAIN}:1443?encryption=none&type=tcp&security=tls&sni=${SNI}&fp=${FP}&flow=${FLOW}#${TAG} diff --git a/internal/xrayconfig/VLESS-XHTTP-URI.Scheme b/internal/xrayconfig/VLESS-XHTTP-URI.Scheme index 2562b9f..f09c580 100644 --- a/internal/xrayconfig/VLESS-XHTTP-URI.Scheme +++ b/internal/xrayconfig/VLESS-XHTTP-URI.Scheme @@ -1 +1 @@ -vless://${UUID}@${DOMAIN}:443?encryption=none&type=xhttp&security=tls&host=${DOMAIN}&path=${PATH}&mode=auto&sni=${DOMAIN}&alpn=h2%2Chttp%2F1.1%2Ch3#${TAG} +vless://${UUID}@${DOMAIN}:443?encryption=none&type=xhttp&security=tls&host=${DOMAIN}&path=${PATH}&mode=${MODE}&sni=${SNI}&fp=${FP}&alpn=h2%2Chttp%2F1.1%2Ch3#${TAG} diff --git a/internal/xrayconfig/template_tcp.json b/internal/xrayconfig/template_tcp.json new file mode 100644 index 0000000..89f4445 --- /dev/null +++ b/internal/xrayconfig/template_tcp.json @@ -0,0 +1,79 @@ +{ + "log": { + "loglevel": "warning" + }, + "routing": { + "domainStrategy": "IPIfNonMatch", + "rules": [ + { + "type": "field", + "ip": [ + "geoip:cn" + ], + "outboundTag": "block" + } + ] + }, + "inbounds": [ + { + "listen": "0.0.0.0", + "port": 1443, + "protocol": "vless", + "settings": { + "clients": [], + "decryption": "none", + "fallbacks": [ + { + "dest": "8001", + "xver": 1 + }, + { + "alpn": "h2", + "dest": "8002", + "xver": 1 + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "rejectUnknownSni": true, + "minVersion": "1.2", + "certificates": [ + { + "ocspStapling": 3600, + "certificateFile": "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{.Domain}}/{{.Domain}}.crt", + "keyFile": "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{.Domain}}/{{.Domain}}.key" + } + ] + } + }, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ] + } + } + ], + "outbounds": [ + { + "protocol": "freedom", + "tag": "direct" + }, + { + "protocol": "blackhole", + "tag": "block" + } + ], + "policy": { + "levels": { + "0": { + "handshake": 2, + "connIdle": 120 + } + } + } +} \ No newline at end of file diff --git a/internal/xrayconfig/template_xhttp.json b/internal/xrayconfig/template_xhttp.json new file mode 100644 index 0000000..77062f8 --- /dev/null +++ b/internal/xrayconfig/template_xhttp.json @@ -0,0 +1,46 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "listen": "/dev/shm/xray.sock,0666", + "protocol": "vless", + "settings": { + "clients": [], + "decryption": "none" + }, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "mode": "auto", + "path": "/split" + } + } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom", + "settings": {} + }, + { + "tag": "blocked", + "protocol": "blackhole", + "settings": {} + } + ], + "routing": { + "domainStrategy": "AsIs", + "rules": [ + { + "type": "field", + "ip": [ + "geoip:private" + ], + "outboundTag": "blocked" + } + ] + } +} \ No newline at end of file diff --git a/internal/xrayconfig/templates.go b/internal/xrayconfig/templates.go index cf0944e..5f5d622 100644 --- a/internal/xrayconfig/templates.go +++ b/internal/xrayconfig/templates.go @@ -11,6 +11,12 @@ var ( //go:embed template_xhttp.json xhttpTemplateJSON []byte + + //go:embed VLESS-TCP-URI.Scheme + vlessTCPScheme []byte + + //go:embed VLESS-XHTTP-URI.Scheme + vlessXHTTPScheme []byte ) // DefaultDefinition returns the built-in Xray configuration definition used when @@ -30,3 +36,13 @@ func TCPDefinition() Definition { func XHTTPDefinition() Definition { return JSONDefinition{Raw: append([]byte(nil), xhttpTemplateJSON...)} } + +// VLESSTCPScheme returns the embedded VLESS URI template for TCP transport. +func VLESSTCPScheme() string { + return string(append([]byte(nil), vlessTCPScheme...)) +} + +// VLESSXHTTPScheme returns the embedded VLESS URI template for XHTTP transport. +func VLESSXHTTPScheme() string { + return string(append([]byte(nil), vlessXHTTPScheme...)) +}