633 lines
16 KiB
Go
633 lines
16 KiB
Go
package xboard
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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"`
|
|
Port int `json:"port"`
|
|
Protocol string `json:"protocol"`
|
|
Settings json.RawMessage `json:"settings"`
|
|
StreamSettings json.RawMessage `json:"streamSettings"`
|
|
}
|
|
|
|
type XInnerConfig struct {
|
|
Port int `json:"port"`
|
|
Protocol string `json:"protocol"`
|
|
Settings json.RawMessage `json:"settings"`
|
|
StreamSettings json.RawMessage `json:"streamSettings"`
|
|
}
|
|
|
|
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 (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
|
|
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 to flat if still empty
|
|
if inner.Protocol == "" {
|
|
inner.Protocol = config.Protocol
|
|
}
|
|
if inner.Port == 0 {
|
|
inner.Port = config.Port
|
|
}
|
|
if inner.Settings == nil {
|
|
inner.Settings = config.Settings
|
|
}
|
|
if inner.StreamSettings == nil {
|
|
inner.StreamSettings = config.StreamSettings
|
|
}
|
|
|
|
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 inboundOptions any
|
|
switch protocol {
|
|
case "vless":
|
|
vlessOptions := option.VLESSInboundOptions{
|
|
ListenOptions: option.ListenOptions{
|
|
ListenPort: uint16(inner.Port),
|
|
},
|
|
}
|
|
|
|
// Handle Reality
|
|
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]
|
|
}
|
|
vlessOptions.TLS = &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()),
|
|
},
|
|
}
|
|
}
|
|
inboundOptions = vlessOptions
|
|
case "vmess":
|
|
vmessOptions := option.VMessInboundOptions{
|
|
ListenOptions: option.ListenOptions{
|
|
ListenPort: uint16(inner.Port),
|
|
},
|
|
}
|
|
inboundOptions = vmessOptions
|
|
case "shadowsocks":
|
|
ssOptions := option.ShadowsocksInboundOptions{
|
|
ListenOptions: option.ListenOptions{
|
|
ListenPort: uint16(inner.Port),
|
|
},
|
|
}
|
|
inboundOptions = ssOptions
|
|
case "trojan":
|
|
trojanOptions := option.TrojanInboundOptions{
|
|
ListenOptions: option.ListenOptions{
|
|
ListenPort: uint16(inner.Port),
|
|
},
|
|
}
|
|
inboundOptions = trojanOptions
|
|
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 dynamic inbound [", inboundTag, "] created on port ", inner.Port, " (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()
|
|
|
|
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 {
|
|
s.logger.Debug("Xboard decoder error: ", err)
|
|
s.logger.Debug("Xboard raw config response: ", string(body))
|
|
return nil, err
|
|
}
|
|
|
|
// Final safety check: if everything we need is empty, log it anyway
|
|
if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" {
|
|
s.logger.Debug("Xboard config mapping failed. Raw response: ", 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
|
|
}
|
|
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
|
|
newUsers := make(map[string]userData)
|
|
for _, u := range users {
|
|
key := u.ResolveKey()
|
|
if key == "" {
|
|
continue
|
|
}
|
|
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"`
|
|
}
|
|
err = json.Unmarshal(body, &result)
|
|
if err != nil {
|
|
s.logger.Debug("Xboard raw user response: ", string(body))
|
|
return nil, err
|
|
}
|
|
return result.Data, nil
|
|
}
|