package xboard import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/netip" "net/url" "strconv" "strings" "sync" "time" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/service" ) // ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64. // This matches the logic in V2bX/core/sing/user.go. func ss2022Key(seed string, keyLen int) string { if len(seed) > keyLen { seed = seed[:keyLen] } else if len(seed) < keyLen { padded := make([]byte, keyLen) copy(padded, []byte(seed)) seed = string(padded) } return base64.StdEncoding.EncodeToString([]byte(seed)) } // ss2022KeyLength returns the required key length for a given SS2022 cipher. func ss2022KeyLength(cipher string) int { switch cipher { case "2022-blake3-aes-128-gcm": return 16 case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305": return 32 default: return 32 } } func RegisterService(registry *boxService.Registry) { boxService.Register[option.XBoardServiceOptions](registry, C.TypeXBoard, NewService) } type Service struct { boxService.Adapter ctx context.Context cancel context.CancelFunc logger log.ContextLogger options option.XBoardServiceOptions httpClient *http.Client traffics map[string]*ssmapi.TrafficManager users map[string]*ssmapi.UserManager servers map[string]adapter.ManagedSSMServer localUsers map[string]userData aliveUsers map[string]map[string]time.Time inboundTags []string syncTicker *time.Ticker reportTicker *time.Ticker aliveTicker *time.Ticker access sync.Mutex router adapter.Router inboundManager adapter.InboundManager protocol string vlessFlow string vlessServerName string ssCipher string // stored for user key derivation in syncUsers ssServerKey string // stored for SS2022 per-user key extraction } type XNodeConfig struct { NodeType string `json:"node_type"` NodeType_ string `json:"nodeType"` ServerConfig json.RawMessage `json:"server_config"` ServerConfig_ json.RawMessage `json:"serverConfig"` Config json.RawMessage `json:"config"` ListenIP string `json:"listen_ip"` Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` Cipher string `json:"cipher"` ServerKey string `json:"server_key"` TLS int `json:"tls"` Flow string `json:"flow"` TLSSettings *XTLSSettings `json:"tls_settings"` TLSSettings_ *XTLSSettings `json:"tlsSettings"` PublicKey string `json:"public_key,omitempty"` PrivateKey string `json:"private_key,omitempty"` ShortID string `json:"short_id,omitempty"` ShortIDs []string `json:"short_ids,omitempty"` Dest string `json:"dest,omitempty"` ServerName string `json:"server_name,omitempty"` ServerPortText string `json:"server_port_text,omitempty"` Network string `json:"network"` DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"` AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` // Hysteria / Hysteria2 UpMbps int `json:"up_mbps"` DownMbps int `json:"down_mbps"` Obfs string `json:"obfs"` ObfsPassword string `json:"obfs-password"` Ignore_Client_Bandwidth bool `json:"ignore_client_bandwidth"` // Tuic CongestionControl string `json:"congestion_control"` ZeroRTTHandshake bool `json:"zero_rtt_handshake"` // AnyTls PaddingScheme []string `json:"padding_scheme"` // TLS certificate settings CertConfig *XCertConfig `json:"cert_config,omitempty"` AutoTLS bool `json:"auto_tls,omitempty"` Domain string `json:"domain,omitempty"` } type XCertConfig struct { CertMode string `json:"cert_mode"` Domain string `json:"domain"` Email string `json:"email"` DNSProvider string `json:"dns_provider"` DNSEnv map[string]string `json:"dns_env"` HTTPPort int `json:"http_port"` CertFile string `json:"cert_file"` KeyFile string `json:"key_file"` CertContent string `json:"cert_content"` KeyContent string `json:"key_content"` } type XInnerConfig struct { ListenIP string `json:"listen_ip"` Port int `json:"port"` ServerPort int `json:"server_port"` Protocol string `json:"protocol"` NodeType string `json:"node_type"` Cipher string `json:"cipher"` ServerKey string `json:"server_key"` TLS int `json:"tls"` Flow string `json:"flow"` TLSSettings *XTLSSettings `json:"tls_settings"` TLSSettings_ *XTLSSettings `json:"tlsSettings"` PublicKey string `json:"public_key,omitempty"` PrivateKey string `json:"private_key,omitempty"` ShortID string `json:"short_id,omitempty"` ShortIDs []string `json:"short_ids,omitempty"` Dest string `json:"dest,omitempty"` ServerName string `json:"server_name,omitempty"` Network string `json:"network"` DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"` AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` StreamSettings json.RawMessage `json:"streamSettings"` UpMbps int `json:"up_mbps"` DownMbps int `json:"down_mbps"` CertConfig *XCertConfig `json:"cert_config,omitempty"` AutoTLS bool `json:"auto_tls,omitempty"` Domain string `json:"domain,omitempty"` } type XMultiplexConfig struct { Enabled bool `json:"enabled"` Protocol string `json:"protocol,omitempty"` MaxConnections int `json:"max_connections,omitempty"` MinStreams int `json:"min_streams,omitempty"` MaxStreams int `json:"max_streams,omitempty"` Padding bool `json:"padding,omitempty"` Brutal *XBrutalConfig `json:"brutal,omitempty"` } type XBrutalConfig struct { Enabled bool `json:"enabled,omitempty"` UpMbps int `json:"up_mbps,omitempty"` DownMbps int `json:"down_mbps,omitempty"` } type HttpNetworkConfig struct { Header struct { Type string `json:"type"` Request *json.RawMessage `json:"request"` Response *json.RawMessage `json:"response"` } `json:"header"` } type HttpRequest struct { Version string `json:"version"` Method string `json:"method"` Path []string `json:"path"` Headers struct { Host []string `json:"Host"` } `json:"headers"` } type WsNetworkConfig struct { Path string `json:"path"` Headers map[string]string `json:"headers"` } type GrpcNetworkConfig struct { ServiceName string `json:"serviceName"` } func unmarshalNetworkSettings(settings json.RawMessage) map[string]any { if len(settings) == 0 { return nil } var raw map[string]any if err := json.Unmarshal(settings, &raw); err != nil { return nil } return raw } func readNetworkString(raw map[string]any, keys ...string) string { for _, key := range keys { value, exists := raw[key] if !exists { continue } if stringValue, ok := value.(string); ok && stringValue != "" { return stringValue } } return "" } func readNetworkBool(raw map[string]any, keys ...string) (bool, bool) { for _, key := range keys { value, exists := raw[key] if !exists { continue } if boolValue, ok := value.(bool); ok { return boolValue, true } } return false, false } func readNetworkDuration(raw map[string]any, keys ...string) (badoption.Duration, bool) { for _, key := range keys { value, exists := raw[key] if !exists { continue } switch typedValue := value.(type) { case float64: return badoption.Duration(time.Duration(typedValue) * time.Second), true case string: if typedValue == "" { continue } if durationValue, err := time.ParseDuration(typedValue); err == nil { return badoption.Duration(durationValue), true } if secondsValue, err := strconv.ParseInt(typedValue, 10, 64); err == nil { return badoption.Duration(time.Duration(secondsValue) * time.Second), true } } } return 0, false } type HttpupgradeNetworkConfig struct { Path string `json:"path"` Host string `json:"host"` } type XTLSSettings struct { ServerName string `json:"server_name"` ServerPort string `json:"server_port"` PublicKey string `json:"public_key"` PrivateKey string `json:"private_key"` ShortID string `json:"short_id"` ShortIDs []string `json:"short_ids"` AllowInsecure bool `json:"allow_insecure"` Dest string `json:"dest"` } type XRealitySettings struct { Dest string `json:"dest"` ServerNames []string `json:"serverNames"` ServerNames_ []string `json:"server_names"` PrivateKey string `json:"privateKey"` PrivateKey_ string `json:"private_key"` ShortId string `json:"shortId"` ShortId_ string `json:"short_id"` ShortIds []string `json:"shortIds"` ShortIds_ []string `json:"short_ids"` } func (r *XRealitySettings) GetPrivateKey() string { if r.PrivateKey != "" { return r.PrivateKey } return r.PrivateKey_ } func (r *XRealitySettings) GetShortIds() []string { if len(r.ShortIds) > 0 { return r.ShortIds } if len(r.ShortIds_) > 0 { return r.ShortIds_ } if r.ShortId != "" { return []string{r.ShortId} } if r.ShortId_ != "" { return []string{r.ShortId_} } return nil } func (r *XRealitySettings) GetServerNames() []string { if len(r.ServerNames) > 0 { return r.ServerNames } return r.ServerNames_ } type XStreamSettings struct { Network string `json:"network"` Security string `json:"security"` RealitySettings XRealitySettings `json:"realitySettings"` RealitySettings_ XRealitySettings `json:"reality_settings"` } func (s *XStreamSettings) GetReality() *XRealitySettings { if s.RealitySettings.GetPrivateKey() != "" { return &s.RealitySettings } return &s.RealitySettings_ } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { if len(options.Nodes) > 0 { return newMultiNodeService(ctx, logger, tag, options) } return newSingleService(ctx, logger, tag, options) } func newSingleService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) { ctx, cancel := context.WithCancel(ctx) s := &Service{ Adapter: boxService.NewAdapter(C.TypeXBoard, tag), ctx: ctx, cancel: cancel, logger: logger, options: options, httpClient: &http.Client{Timeout: 10 * time.Second}, traffics: make(map[string]*ssmapi.TrafficManager), users: make(map[string]*ssmapi.UserManager), servers: make(map[string]adapter.ManagedSSMServer), aliveUsers: make(map[string]map[string]time.Time), syncTicker: time.NewTicker(time.Duration(options.SyncInterval)), reportTicker: time.NewTicker(time.Duration(options.ReportInterval)), aliveTicker: time.NewTicker(1 * time.Minute), router: service.FromContext[adapter.Router](ctx), inboundManager: service.FromContext[adapter.InboundManager](ctx), } if s.options.SyncInterval == 0 { s.syncTicker.Stop() s.syncTicker = time.NewTicker(1 * time.Minute) } if s.options.ReportInterval == 0 { s.reportTicker.Stop() s.reportTicker = time.NewTicker(1 * time.Minute) } s.aliveTicker.Stop() s.aliveTicker = time.NewTicker(1 * time.Minute) return s, nil } func (s *Service) inboundTag() string { if s.Tag() != "" { return "xboard-inbound-" + s.Tag() } if s.options.NodeID != 0 { return "xboard-inbound-" + strconv.Itoa(s.options.NodeID) } return "xboard-inbound" } func applyCertConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig) bool { if certConfig == nil { return false } switch certConfig.CertMode { case "", "none": return false case "file": if certConfig.CertFile == "" || certConfig.KeyFile == "" { return false } tlsOptions.CertificatePath = certConfig.CertFile tlsOptions.KeyPath = certConfig.KeyFile return true case "content": if certConfig.CertContent == "" || certConfig.KeyContent == "" { return false } tlsOptions.Certificate = badoption.Listable[string]{certConfig.CertContent} tlsOptions.Key = badoption.Listable[string]{certConfig.KeyContent} return true default: return false } } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func mergedCertConfig(inner XInnerConfig, config *XNodeConfig) *XCertConfig { if inner.CertConfig != nil { return inner.CertConfig } if config != nil { return config.CertConfig } return nil } func hasUsableServerTLS(tlsOptions option.InboundTLSOptions) bool { return tlsOptions.CertificateProvider != nil || tlsOptions.ACME != nil && len(tlsOptions.ACME.Domain) > 0 || (tlsOptions.CertificatePath != "" && tlsOptions.KeyPath != "") || (len(tlsOptions.Certificate) > 0 && len(tlsOptions.Key) > 0) } func applyACMEConfigDetailed(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) (bool, string) { if !autoTLS && certConfig == nil { return false, "acme disabled: no auto_tls and no cert_config" } mode := "" if certConfig != nil { mode = strings.ToLower(strings.TrimSpace(certConfig.CertMode)) } if mode == "" && autoTLS { mode = "http" } if mode != "http" && mode != "dns" { return false, "unsupported cert_mode: " + mode } domain = strings.TrimSpace(domain) if domain == "" { return false, "missing domain/server_name for ACME" } acmeOptions := &option.InboundACMEOptions{ Domain: badoption.Listable[string]{domain}, DataDirectory: "acme", DefaultServerName: domain, } if certConfig != nil { acmeOptions.Email = strings.TrimSpace(certConfig.Email) } switch mode { case "http": acmeOptions.DisableHTTPChallenge = false acmeOptions.DisableTLSALPNChallenge = true if certConfig != nil && certConfig.HTTPPort > 0 && certConfig.HTTPPort != 80 { acmeOptions.AlternativeHTTPPort = uint16(certConfig.HTTPPort) } case "dns": acmeOptions.DisableHTTPChallenge = true acmeOptions.DisableTLSALPNChallenge = true if listenPort > 0 && listenPort != 443 { acmeOptions.AlternativeTLSPort = uint16(listenPort) } } if mode == "dns" && certConfig != nil { dnsProvider := C.NormalizeACMEDNSProvider(certConfig.DNSProvider) if dnsProvider == "" { return false, "missing dns_provider for cert_mode=dns" } dns01 := &option.ACMEDNS01ChallengeOptions{Provider: dnsProvider} switch dnsProvider { case C.DNSProviderCloudflare: dns01.CloudflareOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["CF_API_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_API_TOKEN"]) dns01.CloudflareOptions.ZoneToken = firstNonEmpty(certConfig.DNSEnv["CF_ZONE_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_ZONE_TOKEN"]) if dns01.CloudflareOptions.APIToken == "" && dns01.CloudflareOptions.ZoneToken == "" { return false, "cloudflare dns challenge requires CF_API_TOKEN/CLOUDFLARE_API_TOKEN or CF_ZONE_TOKEN/CLOUDFLARE_ZONE_TOKEN" } case C.DNSProviderAliDNS: dns01.AliDNSOptions.AccessKeyID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_ID"], certConfig.DNSEnv["ALI_ACCESS_KEY_ID"]) dns01.AliDNSOptions.AccessKeySecret = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_SECRET"], certConfig.DNSEnv["ALI_ACCESS_KEY_SECRET"]) dns01.AliDNSOptions.RegionID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_REGION_ID"], certConfig.DNSEnv["ALI_REGION_ID"]) dns01.AliDNSOptions.SecurityToken = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_SECURITY_TOKEN"], certConfig.DNSEnv["ALI_SECURITY_TOKEN"]) if dns01.AliDNSOptions.AccessKeyID == "" || dns01.AliDNSOptions.AccessKeySecret == "" { return false, "alidns dns challenge requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET" } case C.DNSProviderTencentCloud: dns01.TencentCloudOptions.SecretID = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"]) dns01.TencentCloudOptions.SecretKey = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"]) dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"]) dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"]) if dns01.TencentCloudOptions.SecretID == "" || dns01.TencentCloudOptions.SecretKey == "" { return false, "tencentcloud dns challenge requires TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY" } case C.DNSProviderDNSPod: dns01.DNSPodOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["DNSPOD_TOKEN"], certConfig.DNSEnv["API_TOKEN"]) if dns01.DNSPodOptions.APIToken == "" { tencentSecretID := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"]) tencentSecretKey := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"]) if tencentSecretID == "" || tencentSecretKey == "" { return false, "dnspod dns challenge requires DNSPOD_TOKEN or TencentCloud SecretID/SecretKey" } dns01.Provider = C.DNSProviderTencentCloud dns01.TencentCloudOptions.SecretID = tencentSecretID dns01.TencentCloudOptions.SecretKey = tencentSecretKey dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"]) dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"]) } case C.DNSProviderACMEDNS: dns01.ACMEDNSOptions.Username = certConfig.DNSEnv["ACMEDNS_USERNAME"] dns01.ACMEDNSOptions.Password = certConfig.DNSEnv["ACMEDNS_PASSWORD"] dns01.ACMEDNSOptions.Subdomain = certConfig.DNSEnv["ACMEDNS_SUBDOMAIN"] dns01.ACMEDNSOptions.ServerURL = certConfig.DNSEnv["ACMEDNS_SERVER_URL"] if dns01.ACMEDNSOptions.Username == "" || dns01.ACMEDNSOptions.Password == "" || dns01.ACMEDNSOptions.Subdomain == "" || dns01.ACMEDNSOptions.ServerURL == "" { return false, "acmedns dns challenge requires username, password, subdomain and server_url" } default: return false, "unsupported dns_provider: " + dnsProvider } acmeOptions.DNS01Challenge = dns01 } tlsOptions.ACME = acmeOptions return true, "" } func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool { ok, _ := applyACMEConfigDetailed(tlsOptions, certConfig, autoTLS, domain, listenPort) return ok } func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings { tlsSettings := inner.TLSSettings if tlsSettings == nil { tlsSettings = inner.TLSSettings_ } if tlsSettings == nil && config != nil { tlsSettings = config.TLSSettings } if tlsSettings == nil && config != nil { tlsSettings = config.TLSSettings_ } if tlsSettings == nil { tlsSettings = &XTLSSettings{} } if tlsSettings.PublicKey == "" { if inner.PublicKey != "" { tlsSettings.PublicKey = inner.PublicKey } else if config != nil { tlsSettings.PublicKey = config.PublicKey } } if tlsSettings.PrivateKey == "" { if inner.PrivateKey != "" { tlsSettings.PrivateKey = inner.PrivateKey } else if config != nil { tlsSettings.PrivateKey = config.PrivateKey } } if tlsSettings.ShortID == "" { if inner.ShortID != "" { tlsSettings.ShortID = inner.ShortID } else if config != nil { tlsSettings.ShortID = config.ShortID } } if len(tlsSettings.ShortIDs) == 0 { if len(inner.ShortIDs) > 0 { tlsSettings.ShortIDs = inner.ShortIDs } else if config != nil && len(config.ShortIDs) > 0 { tlsSettings.ShortIDs = config.ShortIDs } } if tlsSettings.Dest == "" { if inner.Dest != "" { tlsSettings.Dest = inner.Dest } else if config != nil { tlsSettings.Dest = config.Dest } } if tlsSettings.ServerName == "" { if inner.ServerName != "" { tlsSettings.ServerName = inner.ServerName } else if config != nil { tlsSettings.ServerName = config.ServerName } } if tlsSettings.ServerPort == "" && config != nil && config.ServerPortText != "" { tlsSettings.ServerPort = config.ServerPortText } return tlsSettings } func buildInboundMultiplex(config *XMultiplexConfig) *option.InboundMultiplexOptions { if config == nil || !config.Enabled { return nil } var brutal *option.BrutalOptions if config.Brutal != nil { brutal = &option.BrutalOptions{ Enabled: config.Brutal.Enabled, UpMbps: config.Brutal.UpMbps, DownMbps: config.Brutal.DownMbps, } } return &option.InboundMultiplexOptions{ Enabled: config.Enabled, Padding: config.Padding, Brutal: brutal, } } func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } // Fetch node config and setup inbound err := s.setupNode() if err != nil { s.logger.Error("Xboard setup error: ", err) // Don't return error to allow sing-box to continue, service will retry in loop } go s.loop() return nil } func getInboundTransport(network string, settings json.RawMessage) (*option.V2RayTransportOptions, error) { if network == "" { return nil, nil } t := &option.V2RayTransportOptions{ Type: network, } rawSettings := unmarshalNetworkSettings(settings) switch network { case "tcp": if len(settings) != 0 { var networkConfig HttpNetworkConfig err := json.Unmarshal(settings, &networkConfig) if err != nil { return nil, fmt.Errorf("decode NetworkSettings error: %s", err) } if networkConfig.Header.Type == "http" { t.Type = networkConfig.Header.Type if networkConfig.Header.Request != nil { var request HttpRequest err = json.Unmarshal(*networkConfig.Header.Request, &request) if err != nil { return nil, fmt.Errorf("decode HttpRequest error: %s", err) } t.HTTPOptions.Host = request.Headers.Host if len(request.Path) > 0 { t.HTTPOptions.Path = request.Path[0] } t.HTTPOptions.Method = request.Method } } else { t.Type = "" } } else { t.Type = "" } case "ws": var ( path string ed int headers map[string]badoption.Listable[string] ) if len(settings) != 0 { var networkConfig WsNetworkConfig err := json.Unmarshal(settings, &networkConfig) if err != nil { return nil, fmt.Errorf("decode NetworkSettings error: %s", err) } u, err := url.Parse(networkConfig.Path) if err != nil { return nil, fmt.Errorf("parse WS path error: %s", err) } path = u.Path ed, _ = strconv.Atoi(u.Query().Get("ed")) if len(networkConfig.Headers) > 0 { headers = make(map[string]badoption.Listable[string], len(networkConfig.Headers)) for k, v := range networkConfig.Headers { headers[k] = badoption.Listable[string]{v} } } } t.WebsocketOptions = option.V2RayWebsocketOptions{ Path: path, EarlyDataHeaderName: "Sec-WebSocket-Protocol", MaxEarlyData: uint32(ed), Headers: headers, } case "grpc": if len(settings) != 0 { var networkConfig GrpcNetworkConfig err := json.Unmarshal(settings, &networkConfig) if err != nil { return nil, fmt.Errorf("decode gRPC settings error: %s", err) } t.GRPCOptions = option.V2RayGRPCOptions{ ServiceName: networkConfig.ServiceName, } if serviceName := readNetworkString(rawSettings, "service_name"); serviceName != "" && t.GRPCOptions.ServiceName == "" { t.GRPCOptions.ServiceName = serviceName } if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { t.GRPCOptions.IdleTimeout = idleTimeout } if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { t.GRPCOptions.PingTimeout = pingTimeout } if permitWithoutStream, ok := readNetworkBool(rawSettings, "permit_without_stream", "permitWithoutStream"); ok { t.GRPCOptions.PermitWithoutStream = permitWithoutStream } } case "httpupgrade": if len(settings) != 0 { var networkConfig HttpupgradeNetworkConfig err := json.Unmarshal(settings, &networkConfig) if err != nil { return nil, fmt.Errorf("decode HttpUpgrade settings error: %s", err) } t.HTTPUpgradeOptions = option.V2RayHTTPUpgradeOptions{ Path: networkConfig.Path, Host: networkConfig.Host, } } case "h2", "http": t.Type = "http" if rawSettings != nil { t.HTTPOptions = option.V2RayHTTPOptions{ Path: readNetworkString(rawSettings, "path"), Method: readNetworkString(rawSettings, "method"), Headers: nil, } if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { t.HTTPOptions.IdleTimeout = idleTimeout } if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { t.HTTPOptions.PingTimeout = pingTimeout } if hostValue, exists := rawSettings["host"]; exists { switch typedHost := hostValue.(type) { case string: if typedHost != "" { t.HTTPOptions.Host = badoption.Listable[string]{typedHost} } case []any: hostList := make(badoption.Listable[string], 0, len(typedHost)) for _, item := range typedHost { if host, ok := item.(string); ok && host != "" { hostList = append(hostList, host) } } if len(hostList) > 0 { t.HTTPOptions.Host = hostList } } } if headersValue, exists := rawSettings["headers"]; exists { if headersMap, ok := headersValue.(map[string]any); ok { headerOptions := make(badoption.HTTPHeader) for headerKey, headerValue := range headersMap { switch typedHeader := headerValue.(type) { case string: if typedHeader != "" { headerOptions[headerKey] = badoption.Listable[string]{typedHeader} } case []any: values := make(badoption.Listable[string], 0, len(typedHeader)) for _, item := range typedHeader { if headerString, ok := item.(string); ok && headerString != "" { values = append(values, headerString) } } if len(values) > 0 { headerOptions[headerKey] = values } } } if len(headerOptions) > 0 { t.HTTPOptions.Headers = headerOptions } } } } } return t, nil } func networkAcceptProxyProtocol(settings json.RawMessage) bool { if len(settings) == 0 { return false } var raw map[string]any if err := json.Unmarshal(settings, &raw); err != nil { return false } for _, key := range []string{"acceptProxyProtocol", "accept_proxy_protocol"} { value, exists := raw[key] if !exists { continue } enabled, ok := value.(bool) if ok && enabled { return true } } return false } func acceptProxyProtocolEnabled(inner XInnerConfig, config *XNodeConfig) bool { if inner.AcceptProxyProtocol || inner.AcceptProxyProtocol_ { return true } if config != nil && (config.AcceptProxyProtocol || config.AcceptProxyProtocol_) { return true } if networkAcceptProxyProtocol(inner.NetworkSettings) || networkAcceptProxyProtocol(inner.NetworkSettings_) { return true } if config != nil && (networkAcceptProxyProtocol(config.NetworkSettings) || networkAcceptProxyProtocol(config.NetworkSettings_)) { return true } return false } const ( defaultXboardTCPKeepAlive = 30 * time.Second defaultXboardTCPKeepAliveInterval = 15 * time.Second ) func resolveTCPKeepAlive(inner XInnerConfig, config *XNodeConfig, settings json.RawMessage) (bool, badoption.Duration, badoption.Duration) { disableKeepAlive := inner.DisableTCPKeepAlive || inner.DisableTCPKeepAlive_ if !disableKeepAlive && config != nil { disableKeepAlive = config.DisableTCPKeepAlive || config.DisableTCPKeepAlive_ } if !disableKeepAlive { if networkDisable, ok := readNetworkBool(unmarshalNetworkSettings(settings), "disable_tcp_keep_alive", "disableTcpKeepAlive"); ok { disableKeepAlive = networkDisable } } if disableKeepAlive { return true, 0, 0 } keepAlive := inner.TCPKeepAlive if keepAlive == 0 { keepAlive = inner.TCPKeepAlive_ } if keepAlive == 0 && config != nil { keepAlive = config.TCPKeepAlive } if keepAlive == 0 && config != nil { keepAlive = config.TCPKeepAlive_ } if keepAlive == 0 { if networkKeepAlive, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive", "tcpKeepAlive"); ok { keepAlive = networkKeepAlive } } if keepAlive == 0 { keepAlive = badoption.Duration(defaultXboardTCPKeepAlive) } keepAliveInterval := inner.TCPKeepAliveInterval if keepAliveInterval == 0 { keepAliveInterval = inner.TCPKeepAliveInterval_ } if keepAliveInterval == 0 && config != nil { keepAliveInterval = config.TCPKeepAliveInterval } if keepAliveInterval == 0 && config != nil { keepAliveInterval = config.TCPKeepAliveInterval_ } if keepAliveInterval == 0 { if networkKeepAliveInterval, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive_interval", "tcpKeepAliveInterval"); ok { keepAliveInterval = networkKeepAliveInterval } } if keepAliveInterval == 0 { keepAliveInterval = badoption.Duration(defaultXboardTCPKeepAliveInterval) } return false, keepAlive, keepAliveInterval } func (s *Service) setupNode() error { s.logger.Info("Xboard fetching node config...") config, err := s.fetchConfig() if err != nil { return err } inboundTag := s.inboundTag() // Resolve nested config (V2bX compatibility: server_config / serverConfig / config) var inner XInnerConfig if len(config.ServerConfig) > 0 { json.Unmarshal(config.ServerConfig, &inner) } else if len(config.ServerConfig_) > 0 { json.Unmarshal(config.ServerConfig_, &inner) } else if len(config.Config) > 0 { json.Unmarshal(config.Config, &inner) } // Fallback flat fields from top-level config to inner if inner.ListenIP == "" { inner.ListenIP = config.ListenIP } if inner.ListenIP == "" { inner.ListenIP = "0.0.0.0" } if inner.TLSSettings == nil { inner.TLSSettings = config.TLSSettings } if inner.TLSSettings == nil { inner.TLSSettings = config.TLSSettings_ } if inner.TLSSettings_ == nil { inner.TLSSettings_ = config.TLSSettings_ } if inner.TLS == 0 { inner.TLS = config.TLS } if inner.Flow == "" { inner.Flow = config.Flow } if inner.Protocol == "" { inner.Protocol = config.Protocol } if inner.Protocol == "" { inner.Protocol = config.NodeType } portSource := "" if inner.Port == 0 { if inner.ServerPort != 0 { inner.Port = inner.ServerPort portSource = "inner.server_port" } else if config.Port != 0 { inner.Port = config.Port portSource = "config.port" } else { inner.Port = config.ServerPort if config.ServerPort != 0 { portSource = "config.server_port" } } } else { portSource = "inner.port" } if inner.Cipher == "" { inner.Cipher = config.Cipher } if inner.ServerKey == "" { inner.ServerKey = config.ServerKey } if inner.Network == "" { inner.Network = config.Network } if inner.Multiplex == nil { inner.Multiplex = config.Multiplex } if len(inner.NetworkSettings) == 0 { inner.NetworkSettings = config.NetworkSettings } if len(inner.NetworkSettings_) == 0 { inner.NetworkSettings_ = config.NetworkSettings_ } if inner.CertConfig == nil { inner.CertConfig = config.CertConfig } if !inner.AutoTLS { inner.AutoTLS = config.AutoTLS } if inner.Domain == "" { inner.Domain = config.Domain } // Resolve protocol protocol := inner.Protocol if protocol == "" { protocol = config.Protocol } if protocol == "" { protocol = inner.NodeType } if protocol == "" { protocol = config.NodeType } if protocol == "" && inner.Cipher != "" { // Fallback for shadowsocks where protocol might be missing but cipher is present protocol = "shadowsocks" } if protocol == "" { s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.") return fmt.Errorf("unsupported protocol: empty") } if inner.Port == 0 { return fmt.Errorf("missing listen port for protocol %s", protocol) } s.logger.Info("Xboard protocol identified: ", protocol) s.protocol = protocol s.vlessFlow = "" s.vlessServerName = "" var listenAddr badoption.Addr if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { listenAddr = badoption.Addr(addr) } else { listenAddr = badoption.Addr(netip.IPv4Unspecified()) } tcpNetworkSettings := inner.NetworkSettings if len(tcpNetworkSettings) == 0 { tcpNetworkSettings = inner.NetworkSettings_ } listen := option.ListenOptions{ Listen: &listenAddr, ListenPort: uint16(inner.Port), } disableTCPKeepAlive, tcpKeepAlive, tcpKeepAliveInterval := resolveTCPKeepAlive(inner, config, tcpNetworkSettings) listen.DisableTCPKeepAlive = disableTCPKeepAlive if !disableTCPKeepAlive { listen.TCPKeepAlive = tcpKeepAlive listen.TCPKeepAliveInterval = tcpKeepAliveInterval s.logger.Info( "Xboard TCP keepalive configured. idle=", time.Duration(tcpKeepAlive), ", interval=", time.Duration(tcpKeepAliveInterval), ) } else { s.logger.Warn("Xboard TCP keepalive disabled by panel config") } if acceptProxyProtocolEnabled(inner, config) { listen.ProxyProtocol = true s.logger.Info("Xboard PROXY protocol enabled for inbound on ", inner.ListenIP, ":", inner.Port) } // ── TLS / Reality handling (matching V2bX panel.Security constants) ── // V2bX: 0=None, 1=TLS, 2=Reality var tlsOptions option.InboundTLSOptions securityType := inner.TLS tlsSettings := mergedTLSSettings(inner, config) if tlsSettings != nil && tlsSettings.ServerName != "" { s.vlessServerName = tlsSettings.ServerName } certConfig := mergedCertConfig(inner, config) hasCertificate := applyCertConfig(&tlsOptions, certConfig) configDomain := "" configAutoTLS := false if config != nil { configDomain = config.Domain configAutoTLS = config.AutoTLS } certDomain := "" if certConfig != nil { certDomain = certConfig.Domain } autoTLSDomain := firstNonEmpty(inner.Domain, configDomain, certDomain) if autoTLSDomain == "" && tlsSettings != nil { autoTLSDomain = tlsSettings.ServerName } hasACME := false acmeReason := "" if !hasCertificate { hasACME, acmeReason = applyACMEConfigDetailed(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port) } if certConfig != nil && !hasCertificate && !hasACME && certConfig.CertMode != "" && certConfig.CertMode != "none" { s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode, ", reason=", acmeReason) } if hasACME { s.logger.Info("Xboard ACME configured for domain ", autoTLSDomain) } switch securityType { case 1: // TLS if tlsSettings != nil { tlsOptions.ServerName = tlsSettings.ServerName } tlsOptions.Enabled = hasCertificate case 2: // Reality tlsOptions.Enabled = true tlsOptions.ServerName = tlsSettings.ServerName if tlsSettings.ServerName == "" { s.logger.Warn("Xboard REALITY server_name is empty; clients may fail validation") } if tlsSettings.PrivateKey == "" { s.logger.Warn("Xboard REALITY private_key is empty") } shortIDs := tlsSettings.ShortIDs if len(shortIDs) == 0 && tlsSettings.ShortID != "" { shortIDs = []string{tlsSettings.ShortID} } if len(shortIDs) == 0 { s.logger.Warn("Xboard REALITY short_id is empty; falling back to empty short_id") } dest := tlsSettings.Dest if dest == "" { dest = tlsSettings.ServerName } if dest == "" { dest = "www.microsoft.com" } serverPort := uint16(443) if tlsSettings.ServerPort != "" { if port, err := strconv.Atoi(tlsSettings.ServerPort); err == nil && port > 0 { serverPort = uint16(port) } } tlsOptions.Reality = &option.InboundRealityOptions{ Enabled: true, Handshake: option.InboundRealityHandshakeOptions{ ServerOptions: option.ServerOptions{ Server: dest, ServerPort: serverPort, }, }, PrivateKey: tlsSettings.PrivateKey, ShortID: badoption.Listable[string](shortIDs), } if tlsSettings.PublicKey != "" { s.logger.Debug("Xboard REALITY public_key received from panel") } s.logger.Info( "Xboard REALITY configured. server_name=", tlsSettings.ServerName, ", dest=", dest, ", server_port=", serverPort, ", short_id_count=", len(shortIDs), ) } // Also check streamSettings for Reality (legacy Xboard format) if inner.StreamSettings != nil && securityType == 0 { var streamSettings XStreamSettings json.Unmarshal(inner.StreamSettings, &streamSettings) reality := streamSettings.GetReality() if streamSettings.Security == "reality" && reality != nil { serverNames := reality.GetServerNames() serverName := "" if len(serverNames) > 0 { serverName = serverNames[0] } tlsOptions = option.InboundTLSOptions{ Enabled: true, ServerName: serverName, Reality: &option.InboundRealityOptions{ Enabled: true, Handshake: option.InboundRealityHandshakeOptions{ ServerOptions: option.ServerOptions{ Server: reality.Dest, ServerPort: 443, }, }, PrivateKey: reality.GetPrivateKey(), ShortID: badoption.Listable[string](reality.GetShortIds()), }, } securityType = 2 s.logger.Info("Xboard REALITY config from streamSettings") } } if securityType == 1 && !tlsOptions.Enabled && !hasUsableServerTLS(tlsOptions) { s.logger.Warn("Xboard TLS enabled by panel but no usable certificate material found; inbound will run without local TLS") } // ── Resolve network transport settings (V2bX style) ── networkType := inner.Network networkSettings := inner.NetworkSettings if len(networkSettings) == 0 { networkSettings = inner.NetworkSettings_ } // ── Build inbound per protocol (matching V2bX core/sing/node.go) ── multiplex := buildInboundMultiplex(inner.Multiplex) s.logger.Info( "Xboard node config resolved. protocol=", protocol, ", listen_ip=", inner.ListenIP, ", listen_port=", inner.Port, " (source=", portSource, ")", ", inner_server_port=", inner.ServerPort, ", config_port=", config.Port, ", config_server_port=", config.ServerPort, ", network=", networkType, ", tls=", securityType, ", multiplex=", multiplex != nil, ) var inboundOptions any switch protocol { case "vmess", "vless": // Transport for vmess/vless transport, err := getInboundTransport(networkType, networkSettings) if err != nil { return fmt.Errorf("build transport for %s: %w", protocol, err) } if protocol == "vless" { if tlsSettings != nil && tlsSettings.ServerName != "" { s.logger.Info("Xboard VLESS server_name from panel: ", tlsSettings.ServerName) } resolvedFlow := inner.Flow if resolvedFlow == "xtls-rprx-vision" { if !tlsOptions.Enabled || (transport != nil && transport.Type != "") { s.logger.Warn("Xboard VLESS flow xtls-rprx-vision ignored because inbound is not raw TLS/REALITY over TCP") resolvedFlow = "" } } s.vlessFlow = resolvedFlow opts := &option.VLESSInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, Multiplex: multiplex, } if transport != nil { opts.Transport = transport } inboundOptions = opts } else { opts := &option.VMessInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, Multiplex: multiplex, } if transport != nil { opts.Transport = transport } inboundOptions = opts } case "shadowsocks": method := inner.Cipher serverKey := inner.ServerKey if serverKey == "" { serverKey = s.options.Key } if method == "" { method = "aes-256-gcm" } // Store cipher for user key derivation in syncUsers s.ssCipher = method s.ssServerKey = serverKey // V2bX approach: use server_key from panel DIRECTLY as PSK // The panel provides it already in the correct format (base64 for 2022) ssOptions := &option.ShadowsocksInboundOptions{ ListenOptions: listen, Method: method, Multiplex: multiplex, } isSS2022 := strings.Contains(method, "2022") var dummyKey string if isSS2022 { ssOptions.Password = serverKey dummyBytes := make([]byte, ss2022KeyLength(method)) for i := range dummyBytes { dummyBytes[i] = byte(rand.Intn(256)) } dummyKey = base64.StdEncoding.EncodeToString(dummyBytes) } else { dummyKey = "dummy_user_key" } ssOptions.Users = []option.ShadowsocksUser{{ Password: dummyKey, }} if isSS2022 { s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password) } else { // Legacy SS: password-based ssOptions.Password = serverKey s.logger.Info("Xboard Shadowsocks setup. Method: ", method) } inboundOptions = ssOptions case "trojan": if !hasUsableServerTLS(tlsOptions) { return fmt.Errorf("trojan requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") } // Trojan supports ws/grpc transport like V2bX transport, err := getInboundTransport(networkType, networkSettings) if err != nil { return fmt.Errorf("build transport for trojan: %w", err) } opts := &option.TrojanInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, Multiplex: multiplex, } if transport != nil { opts.Transport = transport } inboundOptions = opts case "tuic": if !hasUsableServerTLS(tlsOptions) { return fmt.Errorf("tuic requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") } // V2bX: TUIC always uses TLS with h3 ALPN tuicTLS := tlsOptions tuicTLS.Enabled = true tuicTLS.ALPN = append(tuicTLS.ALPN, "h3") congestionControl := config.CongestionControl if congestionControl == "" { congestionControl = "bbr" } opts := &option.TUICInboundOptions{ ListenOptions: listen, CongestionControl: congestionControl, ZeroRTTHandshake: config.ZeroRTTHandshake, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tuicTLS, }, } inboundOptions = opts s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl) case "hysteria": if !hasUsableServerTLS(tlsOptions) { return fmt.Errorf("hysteria requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") } // V2bX: Hysteria always uses TLS hyTLS := tlsOptions hyTLS.Enabled = true opts := &option.HysteriaInboundOptions{ ListenOptions: listen, UpMbps: config.UpMbps, DownMbps: config.DownMbps, Obfs: config.Obfs, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &hyTLS, }, } inboundOptions = opts s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps) case "hysteria2": if !hasUsableServerTLS(tlsOptions) { return fmt.Errorf("hysteria2 requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") } // V2bX: Hysteria2 always uses TLS, optional obfs hy2TLS := tlsOptions hy2TLS.Enabled = true var obfs *option.Hysteria2Obfs if config.Obfs != "" && config.ObfsPassword != "" { obfs = &option.Hysteria2Obfs{ Type: config.Obfs, Password: config.ObfsPassword, } } else if config.Obfs != "" { // V2bX compat: if only obfs type given, treat as salamander with obfs as password obfs = &option.Hysteria2Obfs{ Type: "salamander", Password: config.Obfs, } } opts := &option.Hysteria2InboundOptions{ ListenOptions: listen, UpMbps: config.UpMbps, DownMbps: config.DownMbps, IgnoreClientBandwidth: config.Ignore_Client_Bandwidth, Obfs: obfs, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &hy2TLS, }, } inboundOptions = opts s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth) case "anytls": if !hasUsableServerTLS(tlsOptions) { return fmt.Errorf("anytls requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain") } // V2bX: AnyTLS always uses TLS anyTLS := tlsOptions anyTLS.Enabled = true opts := &option.AnyTLSInboundOptions{ ListenOptions: listen, PaddingScheme: config.PaddingScheme, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &anyTLS, }, } inboundOptions = opts s.logger.Info("Xboard AnyTLS configured") default: return fmt.Errorf("unsupported protocol: %s", protocol) } if !tlsOptions.Enabled && securityType == 1 && (protocol == "trojan" || protocol == "tuic" || protocol == "hysteria" || protocol == "hysteria2" || protocol == "anytls") { s.logger.Warn("Xboard ", protocol, " usually requires local TLS certificates, but no local certificate material was configured") } // Remove old if exists s.inboundManager.Remove(inboundTag) s.logger.Info("Xboard creating inbound [", inboundTag, "] on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")") // Create new inbound err = s.inboundManager.Create(s.ctx, s.router, s.logger, inboundTag, protocol, inboundOptions) if err != nil { return err } s.access.Lock() s.inboundTags = []string{inboundTag} s.access.Unlock() s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")") // Register the new inbound in our managed list inbound, _ := s.inboundManager.Get(inboundTag) managedServer, isManaged := inbound.(adapter.ManagedSSMServer) if isManaged { tracker := newXboardTracker(s) traffic := tracker.TrafficManager() managedServer.SetTracker(tracker) user := ssmapi.NewUserManager(managedServer, traffic) s.access.Lock() s.traffics[inboundTag] = traffic s.users[inboundTag] = user s.servers[inboundTag] = managedServer s.inboundTags = []string{inboundTag} s.access.Unlock() s.logger.Info("Xboard managed inbound [", inboundTag, "] registered (protocol: ", protocol, ")") } return nil } func normalizePanelNodeType(nodeType string) string { switch strings.ToLower(strings.TrimSpace(nodeType)) { case "v2ray": return "vmess" case "hysteria2": return "hysteria" default: return strings.ToLower(strings.TrimSpace(nodeType)) } } func (s *Service) panelRequest(method string, baseURL string, endpoint string, nodeID int, payload []byte, contentType string) (http.Header, []byte, int, error) { nodeType := normalizePanelNodeType(s.options.NodeType) nodeTypeCandidates := []string{""} if nodeType != "" { nodeTypeCandidates = append(nodeTypeCandidates, nodeType) } var lastHeader http.Header var lastBody []byte var lastStatus int for index, candidate := range nodeTypeCandidates { requestURL, err := url.Parse(strings.TrimRight(baseURL, "/") + endpoint) if err != nil { return nil, nil, 0, err } query := requestURL.Query() query.Set("node_id", strconv.Itoa(nodeID)) query.Set("token", s.options.Key) if candidate != "" { query.Set("node_type", candidate) } requestURL.RawQuery = query.Encode() var bodyReader io.Reader if payload != nil { bodyReader = bytes.NewReader(payload) } req, _ := http.NewRequest(method, requestURL.String(), bodyReader) req.Header.Set("User-Agent", "sing-box/xboard") if contentType != "" { req.Header.Set("Content-Type", contentType) } logNodeType := candidate if logNodeType == "" { logNodeType = "" } s.logger.Info("Xboard panel request. endpoint=", endpoint, ", node_id=", nodeID, ", node_type=", logNodeType) resp, err := s.httpClient.Do(req) if err != nil { return nil, nil, 0, err } responseBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, nil, 0, readErr } lastHeader = resp.Header.Clone() lastBody = responseBody lastStatus = resp.StatusCode if resp.StatusCode == 400 && candidate == "" && strings.Contains(string(responseBody), "Server does not exist") && index+1 < len(nodeTypeCandidates) { s.logger.Warn("Xboard panel request failed without node_type, retrying with configured node_type=", nodeType) continue } return lastHeader, lastBody, lastStatus, nil } return lastHeader, lastBody, lastStatus, nil } func (s *Service) fetchConfig() (*XNodeConfig, error) { nodeID := s.options.ConfigNodeID if nodeID == 0 { nodeID = s.options.NodeID } baseURL := s.options.ConfigPanelURL if baseURL == "" { baseURL = s.options.PanelURL } headers, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/config", nodeID, nil, "") if err != nil { return nil, err } // Check time drift if dateStr := headers.Get("Date"); dateStr != "" { if panelTime, err := http.ParseTime(dateStr); err == nil { localTime := time.Now() drift := localTime.Sub(panelTime) if drift < 0 { drift = -drift } s.logger.Info("TIME CHECK: Panel Local [", panelTime.Format(time.RFC3339), "] vs Server Local [", localTime.Format(time.RFC3339), "]. Drift: ", drift) if drift > 30*time.Second { s.logger.Error("CRITICAL TIME DRIFT: Your server time is OUT OF SYNC. Shadowsocks 2022 WILL FAIL!") } } } if statusCode != 200 { return nil, E.New("failed to fetch config, status: ", statusCode, ", body: ", string(body)) } var result struct { Data XNodeConfig `json:"data"` } err = json.Unmarshal(body, &result) if err != nil || (result.Data.Protocol == "" && result.Data.NodeType == "" && result.Data.ServerPort == 0) { // Try unmarshaling WITHOUT "data" wrapper var flatResult XNodeConfig if err2 := json.Unmarshal(body, &flatResult); err2 == nil { s.logger.Info( "Xboard config fetched (flat). protocol=", flatResult.Protocol, ", node_type=", flatResult.NodeType, ", port=", flatResult.Port, ", server_port=", flatResult.ServerPort, ) return &flatResult, nil } s.logger.Error("Xboard decoder error: ", err) s.logger.Error("Xboard raw config response: ", string(body)) return nil, err } // Final safety check if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" && result.Data.ServerPort == 0 { s.logger.Error("Xboard config mapping failed (fields missing). Data: ", string(body)) } s.logger.Info( "Xboard config fetched. protocol=", result.Data.Protocol, ", node_type=", result.Data.NodeType, ", port=", result.Data.Port, ", server_port=", result.Data.ServerPort, ) return &result.Data, nil } func (s *Service) loop() { // Initial sync s.syncUsers() for { select { case <-s.ctx.Done(): return case <-s.syncTicker.C: s.syncUsers() case <-s.reportTicker.C: s.reportTraffic() case <-s.aliveTicker.C: s.sendAlive() } } } type userData struct { ID int Email string Key string Flow string } func (s *Service) syncUsers() { s.logger.Info("Xboard sync users...") users, err := s.fetchUsers() if err != nil { s.logger.Error("Xboard sync error: ", err) return } if len(users) == 0 { s.logger.Warn("Xboard sync: no users returned from panel. Check your Node ID and User config.") } s.access.Lock() defer s.access.Unlock() newUsers := make(map[string]userData) isSS2022 := strings.Contains(s.ssCipher, "2022") ss2022KeyLen := 0 if isSS2022 { ss2022KeyLen = ss2022KeyLength(s.ssCipher) } for _, u := range users { userName := u.Identifier() if userName == "" { continue } key := s.resolveUserKey(u, isSS2022) if key == "" { continue } // V2bX/Xboard approach for SS2022 user key: if isSS2022 { originalKey := key key = ss2022Key(key, ss2022KeyLen) s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key) } flow := u.Flow if s.protocol == "vless" && flow == "" { flow = s.vlessFlow } if s.protocol == "vless" && flow == "xtls-rprx-vision" && s.vlessServerName == "" { s.logger.Warn("Xboard VLESS flow xtls-rprx-vision kept but panel did not provide server_name") } newUsers[userName] = userData{ ID: u.ID, Email: userName, Key: key, Flow: flow, } } if sameUserSet(s.localUsers, newUsers) { s.logger.Trace("Xboard sync skipped: users unchanged") return } for tag, server := range s.servers { // Update users in each manager users := make([]string, 0, len(newUsers)) keys := make([]string, 0, len(newUsers)) flows := make([]string, 0, len(newUsers)) for _, u := range newUsers { users = append(users, u.Email) keys = append(keys, u.Key) flows = append(flows, u.Flow) } err = server.UpdateUsers(users, keys, flows) if err != nil { s.logger.Error("Update users for inbound ", tag, ": ", err) } } // Update local ID mapping s.localUsers = newUsers s.logger.Info("Xboard sync completed, total users: ", len(users)) } func sameUserSet(current map[string]userData, next map[string]userData) bool { if len(current) != len(next) { return false } for userName, currentUser := range current { nextUser, exists := next[userName] if !exists { return false } if currentUser != nextUser { return false } } return true } func (s *Service) reportTraffic() { s.logger.Trace("Xboard reporting traffic...") s.access.Lock() localUsers := s.localUsers s.access.Unlock() if len(localUsers) == 0 { return } usageMap := make(map[string][2]int64) for _, trafficManager := range s.traffics { users := make([]*ssmapi.UserObject, 0, len(localUsers)) for email := range localUsers { users = append(users, &ssmapi.UserObject{UserName: email}) } // Read incremental usage trafficManager.ReadUsers(users, true) for _, u := range users { if u.UplinkBytes == 0 && u.DownlinkBytes == 0 { continue } meta, ok := localUsers[u.UserName] if !ok { continue } userID := strconv.Itoa(meta.ID) item := usageMap[userID] item[0] += u.UplinkBytes item[1] += u.DownlinkBytes usageMap[userID] = item } } if len(usageMap) == 0 { return } err := s.pushTraffic(usageMap) if err != nil { s.logger.Error("Xboard report error: ", err) } else { s.logger.Info("Xboard report completed, users reported: ", len(usageMap)) } } func (s *Service) pushTraffic(data any) error { nodeID := s.options.UserNodeID if nodeID == 0 { nodeID = s.options.NodeID } baseURL := s.options.UserPanelURL if baseURL == "" { baseURL = s.options.PanelURL } body, _ := json.Marshal(data) _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/push", nodeID, body, "application/json") if err != nil { return err } if statusCode != 200 { return E.New("failed to push traffic, status: ", statusCode, ", body: ", string(responseBody)) } return nil } func (s *Service) sendAlive() { nodeID := s.options.UserNodeID if nodeID == 0 { nodeID = s.options.ConfigNodeID } if nodeID == 0 { nodeID = s.options.NodeID } baseURL := s.options.UserPanelURL if baseURL == "" { baseURL = s.options.ConfigPanelURL } if baseURL == "" { baseURL = s.options.PanelURL } payload := s.buildAlivePayload() body, err := json.Marshal(payload) if err != nil { s.logger.Error("Xboard alive payload error: ", err) return } _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/alive", nodeID, body, "application/json") if err != nil { s.logger.Error("Xboard heartbeat error: ", err) return } if statusCode != 200 { s.logger.Warn("Xboard heartbeat failed, status: ", statusCode, ", body: ", string(responseBody)) } else { s.logger.Trace("Xboard heartbeat sent, users: ", len(payload)) } } func (s *Service) Close() error { s.cancel() s.syncTicker.Stop() s.reportTicker.Stop() s.aliveTicker.Stop() return nil } // Xboard User Model type XUser struct { ID int `json:"id"` Email string `json:"email"` UUID string `json:"uuid"` // V2ray/Vless Passwd string `json:"passwd"` // SS Password string `json:"password"` // Trojan/SS alternate Token string `json:"token"` // Alternate Flow string `json:"flow"` } func (u *XUser) Identifier() string { if u.UUID != "" { return u.UUID } if u.Email != "" { return u.Email } if u.ID != 0 { return strconv.Itoa(u.ID) } return "" } func (u *XUser) ResolveKey() string { if u.Passwd != "" { return u.Passwd } if u.Password != "" { return u.Password } if u.UUID != "" { return u.UUID } return u.Token } func (s *Service) resolveUserKey(u XUser, isSS2022 bool) string { key := u.ResolveKey() if !isSS2022 || key == "" { return key } if strings.Contains(key, ":") { serverKey, userKey, ok := strings.Cut(key, ":") if ok && userKey != "" { if s.ssServerKey != "" && serverKey != "" && serverKey != s.ssServerKey { s.logger.Warn("Xboard SS2022 user key server key mismatch for user [", u.ID, "]") } return userKey } } return key } func (s *Service) fetchUsers() ([]XUser, error) { nodeID := s.options.UserNodeID if nodeID == 0 { nodeID = s.options.NodeID } baseURL := s.options.UserPanelURL if baseURL == "" { baseURL = s.options.PanelURL } _, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/user", nodeID, nil, "") if err != nil { return nil, err } if statusCode != 200 { return nil, E.New("failed to fetch users, status: ", statusCode, ", body: ", string(body)) } var result struct { Data []XUser `json:"data"` Users []XUser `json:"users"` } err = json.Unmarshal(body, &result) if err != nil { s.logger.Error("Xboard raw user response: ", string(body)) return nil, err } userList := result.Data if len(userList) == 0 { userList = result.Users } return userList, nil }