package api import ( "errors" "fmt" "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"` 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) { user, ok := h.resolveAgentNodeUser(c) if !ok { return } if !user.Active { c.JSON(http.StatusForbidden, gin.H{ "error": "account_paused", "message": "account is paused", }) return } proxyUUID := strings.TrimSpace(user.ProxyUUID) if proxyUUID == "" { proxyUUID = user.ID } if user.ProxyUUIDExpiresAt != nil && time.Now().UTC().After(*user.ProxyUUIDExpiresAt) { // Sandbox rotates hourly; never block it on expiry. if strings.EqualFold(strings.TrimSpace(user.Email), sandboxUserEmail) { if err := h.ensureSandboxProxyUUID(c.Request.Context(), user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_uuid_rotation_failed"}) return } proxyUUID = strings.TrimSpace(user.ProxyUUID) if proxyUUID == "" { proxyUUID = user.ID } } else { c.JSON(http.StatusForbidden, gin.H{ "error": "proxy_uuid_expired", "message": "proxy access has expired, please renew", }) return } } // Add panic recovery for this handler defer func() { if r := recover(); r != nil { fmt.Printf("PANIC in listAgentNodes: %v\n", r) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error", "message": fmt.Sprintf("%v", r)}) } }() registeredHosts, registeredNames := registeredNodeMetadata(h.agentStatusReader) hosts := parseProxyNodeHosts(h.publicURL, registeredHosts) 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, len(hosts)) for _, host := range hosts { nodeName := resolveNodeName(host, registeredNames) 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), }), }) } // Final safety for Sandbox: if no nodes are available, the UI will be blocked. email := strings.ToLower(strings.TrimSpace(user.Email)) if len(nodes) == 0 && email == sandboxUserEmail { host := normalizeHost(h.publicURL) if host == "" { host = normalizeHost(c.Request.Host) } if host == "" { host = "accounts.svc.plus" } 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 (h *handler) resolveAgentNodeUser(c *gin.Context) (*store.User, bool) { if userID := strings.TrimSpace(auth.GetUserID(c)); userID != "" && userID != "system" { user, err := h.store.GetUserByID(c.Request.Context(), userID) if err != nil { if errors.Is(err, store.ErrUserNotFound) { c.JSON(http.StatusUnauthorized, gin.H{"error": "user_not_found"}) return nil, false } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed_to_fetch_user"}) return nil, false } return user, true } token := extractToken(c.GetHeader("Authorization")) if token == "" { token = strings.TrimSpace(c.Query("token")) } if token == "" { if cookie, err := c.Cookie(sessionCookieName); err == nil { token = strings.TrimSpace(cookie) } } if token == "" { // Console Guest/Demo path: allow the trusted Console BFF to resolve the // sandbox user without requiring an end-user session. if isInternalServiceRequest(c) { sandboxUser, err := h.store.GetUserByEmail(c.Request.Context(), sandboxUserEmail) if err != nil { if errors.Is(err, store.ErrUserNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "sandbox_missing"}) return nil, false } c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_lookup_failed"}) return nil, false } if err := h.ensureSandboxProxyUUID(c.Request.Context(), sandboxUser); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_uuid_rotation_failed"}) return nil, false } return sandboxUser, true } c.JSON(http.StatusUnauthorized, gin.H{"error": "session_token_required", "message": "session token is required"}) return nil, false } sess, ok := h.lookupSession(token) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_session", "message": "session token is invalid or expired"}) return nil, false } user, err := h.store.GetUserByID(c.Request.Context(), sess.userID) if err != nil { if errors.Is(err, store.ErrUserNotFound) { c.JSON(http.StatusUnauthorized, gin.H{"error": "user_not_found"}) return nil, false } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed_to_fetch_user"}) return nil, false } return user, true } func parseProxyNodeHosts(publicURL string, extraHosts []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) } } for _, host := range extraHosts { appendHost(host) } if len(hosts) == 0 { appendHost(publicURL) } // Last resort fallback if len(hosts) == 0 { appendHost("accounts.svc.plus") } return hosts } func normalizeHost(raw string) string { value := strings.TrimSpace(raw) if value == "" || 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 registeredNodeMetadata(reader agentStatusReader) ([]string, map[string]string) { if reader == nil { return nil, nil } snapshots := reader.Statuses() hosts := make([]string, 0, len(snapshots)) names := make(map[string]string, len(snapshots)) for _, snapshot := range snapshots { host := normalizeHost(snapshot.Agent.ID) if host == "" { continue } hosts = append(hosts, host) if displayName := strings.TrimSpace(snapshot.Agent.Name); displayName != "" { names[host] = displayName } } return hosts, names } func resolveNodeName(host string, names map[string]string) string { if len(names) > 0 { if name := strings.TrimSpace(names[host]); name != "" { return name } } return nodeNameForHost(host) } 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 { h := strings.TrimSpace(host) if h == "" { return "unknown-node" } return strings.ToLower(h) } // countryCodeForHost extracts a two-letter country code from the host's // subdomain prefix. For example "jp-xhttp.svc.plus" yields "JP", // "us-east.example.com" yields "US". func countryCodeForHost(host string) string { prefix := host if idx := strings.Index(prefix, "."); idx > 0 { prefix = prefix[:idx] } // Take the part before the first dash: "jp-xhttp" -> "jp" if idx := strings.Index(prefix, "-"); idx > 0 { prefix = prefix[:idx] } prefix = strings.TrimSpace(prefix) if len(prefix) == 2 { return strings.ToUpper(prefix) } return "" } 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 }