Files
SingboxForPanel/service/xboard/service.go
CN-JS-HuiBai 13636715a7 混合处理
2026-04-15 01:30:28 +08:00

1131 lines
31 KiB
Go

package xboard
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"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"
)
// ss2022UserKey prepares a user key for SS2022:
// Truncate UUID string to keyLen and encode as Base64 for sing-box.
func ss2022UserKey(identity string, keyLen int) string {
if len(identity) > keyLen {
identity = identity[:keyLen]
} else if len(identity) < keyLen {
padded := make([]byte, keyLen)
copy(padded, []byte(identity))
identity = string(padded)
}
return base64.StdEncoding.EncodeToString([]byte(identity))
}
// 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
inboundManager adapter.InboundManager
ssCipher string // stored for user key derivation in syncUsers
}
type XBoardServiceOptions struct {
PanelURL string `json:"panel_url"`
ConfigPanelURL string `json:"config_panel_url,omitempty"`
UserPanelURL string `json:"user_panel_url,omitempty"`
Key string `json:"key"`
NodeID int `json:"node_id"`
ConfigNodeID int `json:"config_node_id,omitempty"`
UserNodeID int `json:"user_node_id,omitempty"`
NodeType string `json:"node_type"`
SyncInterval badoption.Duration `json:"sync_interval,omitempty"`
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
}
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"`
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"`
}
type XInnerConfig struct {
ListenIP string `json:"listen_ip"`
Port int `json:"port"`
ServerPort int `json:"server_port"`
Protocol string `json:"protocol"`
Settings json.RawMessage `json:"settings"`
StreamSettings json.RawMessage `json:"streamSettings"`
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"`
Network string `json:"network"`
NetworkSettings json.RawMessage `json:"network_settings"`
NetworkSettings_ json.RawMessage `json:"networkSettings"`
}
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) {
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),
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) 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 := "xboard-inbound"
// 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.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 = config.NodeType
}
if protocol == "" {
protocol = config.NodeType_
}
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")
}
s.logger.Info("Xboard protocol identified: ", protocol)
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 := inner.TLSSettings
if tlsSettings == nil {
tlsSettings = inner.TLSSettings_
}
switch securityType {
case 1: // TLS
tlsOptions.Enabled = true
if tlsSettings != nil {
tlsOptions.ServerName = tlsSettings.ServerName
}
case 2: // Reality
if tlsSettings != nil {
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),
}
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")
}
}
// ── 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
// 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,
}
if strings.Contains(method, "2022") {
// SS2022: server_key is used DIRECTLY as PSK (like V2bX)
ssOptions.Password = serverKey
keyLen := ss2022KeyLength(method)
// Create a dummy user (will be replaced by syncUsers)
dummyKey := make([]byte, keyLen)
_, _ = rand.Read(dummyKey)
ssOptions.Users = []option.ShadowsocksUser{{
Password: base64.StdEncoding.EncodeToString(dummyKey),
}}
s.logger.Info("Xboard SS2022 setup. Method: ", method, " ServerKey(PSK): ", serverKey)
} 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)
}
// Remove old if exists
s.inboundManager.Remove(inboundTag)
// Create new inbound
err = s.inboundManager.Create(s.ctx, nil, 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 port ", 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 {
key := u.ResolveKey()
if key == "" {
continue
}
// V2bX/Xboard approach for SS2022 user key:
// Base64 encode the UUID string (clipped/padded to keyLen)
if isSS2022 {
originalKey := key
key = ss2022UserKey(key, ss2022KeyLen)
if len(newUsers) == 0 {
// Log first user's key derivation for debugging
s.logger.Info("SS2022 user key derivation: ID[:", ss2022KeyLen, "]=", originalKey[:min(ss2022KeyLen, len(originalKey))], " → b64_uPSK=", key)
}
}
newUsers[u.Email] = userData{
ID: u.ID,
Email: u.Email,
Key: key,
Flow: u.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) ResolveKey() string {
if u.UUID != "" {
return u.UUID
}
if u.Passwd != "" {
return u.Passwd
}
if u.Password != "" {
return u.Password
}
return u.Token
}
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
}