feat(vless): support multi-region URI schemes and node metadata

This commit is contained in:
Haitao Pan 2026-02-04 20:48:21 +08:00
parent c7cbf43665
commit 5ff9d0ade0
6 changed files with 327 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
]
}
}

View File

@ -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...))
}