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 inboundTags []string syncTicker *time.Ticker reportTicker *time.Ticker aliveTicker *time.Ticker access sync.Mutex router adapter.Router inboundManager adapter.InboundManager protocol string vlessFlow 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"` 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"` 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"` } 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"` } 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), 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 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 (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, } 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, } } 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, } } } return t, nil } 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.NodeType } if inner.Port == 0 { if config.Port != 0 { inner.Port = config.Port } else { inner.Port = config.ServerPort } } if inner.Cipher == "" { inner.Cipher = config.Cipher } if inner.ServerKey == "" { inner.ServerKey = config.ServerKey } if inner.Network == "" { inner.Network = config.Network } if len(inner.NetworkSettings) == 0 { inner.NetworkSettings = config.NetworkSettings } if len(inner.NetworkSettings_) == 0 { inner.NetworkSettings_ = config.NetworkSettings_ } // Resolve protocol protocol := inner.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 = inner.Flow var listenAddr badoption.Addr if addr, err := netip.ParseAddr(inner.ListenIP); err == nil { listenAddr = badoption.Addr(addr) } else { listenAddr = badoption.Addr(netip.IPv4Unspecified()) } listen := option.ListenOptions{ Listen: &listenAddr, ListenPort: uint16(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) hasCertificate := applyCertConfig(&tlsOptions, config.CertConfig) if config.CertConfig != nil && !hasCertificate && config.CertConfig.CertMode != "" && config.CertConfig.CertMode != "none" { s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", config.CertConfig.CertMode) } 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 shortIDs := tlsSettings.ShortIDs if len(shortIDs) == 0 && tlsSettings.ShortID != "" { shortIDs = []string{tlsSettings.ShortID} } 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. Dest: ", dest, ":", serverPort) } // 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 { 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) ── 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" { opts := &option.VLESSInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, } if transport != nil { opts.Transport = transport } inboundOptions = opts } else { opts := &option.VMessInboundOptions{ ListenOptions: listen, InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &tlsOptions, }, } 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, } 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": // 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, }, } if transport != nil { opts.Transport = transport } inboundOptions = opts case "tuic": // 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": // 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": // 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": // 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) // 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 { traffic := ssmapi.NewTrafficManager() managedServer.SetTracker(traffic) 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 (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 } url := fmt.Sprintf("%s/api/v1/server/UniProxy/config?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Check time drift if dateStr := resp.Header.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 resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) return nil, E.New("failed to fetch config, status: ", resp.Status, ", body: ", string(respBody)) } body, _ := io.ReadAll(resp.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 { 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)) } 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 } newUsers[userName] = userData{ ID: u.ID, Email: userName, Key: key, Flow: flow, } } 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 (s *Service) reportTraffic() { s.logger.Trace("Xboard reporting traffic...") s.access.Lock() localUsers := s.localUsers s.access.Unlock() if len(localUsers) == 0 { return } type pushItem struct { UserID int `json:"user_id"` U int64 `json:"u"` D int64 `json:"d"` } usageMap := make(map[int]*pushItem) 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 } item, ok := usageMap[meta.ID] if !ok { item = &pushItem{UserID: meta.ID} usageMap[meta.ID] = item } item.U += u.UplinkBytes item.D += u.DownlinkBytes } } if len(usageMap) == 0 { return } pushData := make([]*pushItem, 0, len(usageMap)) for _, item := range usageMap { pushData = append(pushData, item) } err := s.pushTraffic(pushData) if err != nil { s.logger.Error("Xboard report error: ", err) } else { s.logger.Info("Xboard report completed, users reported: ", len(pushData)) } } 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 } url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) body, _ := json.Marshal(data) req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) return E.New("failed to push traffic, status: ", resp.Status, ", body: ", string(respBody)) } return nil } func (s *Service) sendAlive() { nodeID := s.options.ConfigNodeID if nodeID == 0 { nodeID = s.options.NodeID } baseURL := s.options.ConfigPanelURL if baseURL == "" { baseURL = s.options.PanelURL } url := fmt.Sprintf("%s/api/v1/server/UniProxy/alive?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) req, _ := http.NewRequest("POST", url, nil) req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { s.logger.Error("Xboard heartbeat error: ", err) return } defer resp.Body.Close() if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) s.logger.Warn("Xboard heartbeat failed, status: ", resp.Status, ", body: ", string(respBody)) } else { s.logger.Trace("Xboard heartbeat sent") } } 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 } url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d&node_type=%s&token=%s", baseURL, nodeID, s.options.NodeType, s.options.Key) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "sing-box/xboard") resp, err := s.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) return nil, E.New("failed to fetch users, status: ", resp.Status, ", body: ", string(respBody)) } body, _ := io.ReadAll(resp.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 }