From dcf4f06dab302d5a5dd1bbd21635b0c58c23d700 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Tue, 14 Apr 2026 22:58:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E4=BA=8EXboard?= =?UTF-8?q?=E7=9A=84=E5=AE=8C=E6=95=B4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .config.example | 12 ++ adapter/ssm.go | 2 +- include/registry.go | 2 + install.sh | 201 ++++++++++++++++++ protocol/anytls/inbound.go | 52 ++++- protocol/shadowsocks/inbound_multi.go | 2 +- protocol/vless/inbound.go | 52 +++++ protocol/vmess/inbound.go | 43 ++++ service/ssmapi/user.go | 2 +- service/xboard/service.go | 294 ++++++++++++++++++++++++++ 10 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 .config.example create mode 100644 install.sh create mode 100644 service/xboard/service.go diff --git a/.config.example b/.config.example new file mode 100644 index 00000000..7089354d --- /dev/null +++ b/.config.example @@ -0,0 +1,12 @@ +{ + "services": [ + { + "type": "xboard", + "panel_url": "https://your-panel.com", + "key": "your-node-key", + "node_id": 1, + "sync_interval": "1m", + "report_interval": "1m" + } + ] +} diff --git a/adapter/ssm.go b/adapter/ssm.go index caab9221..3862e94f 100644 --- a/adapter/ssm.go +++ b/adapter/ssm.go @@ -9,7 +9,7 @@ import ( type ManagedSSMServer interface { Inbound SetTracker(tracker SSMTracker) - UpdateUsers(users []string, uPSKs []string) error + UpdateUsers(users []string, uPSKs []string, flows []string) error } type SSMTracker interface { diff --git a/include/registry.go b/include/registry.go index 5a1a2f97..742a736e 100644 --- a/include/registry.go +++ b/include/registry.go @@ -38,6 +38,7 @@ import ( originca "github.com/sagernet/sing-box/service/origin_ca" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" + "github.com/sagernet/sing-box/service/xboard" E "github.com/sagernet/sing/common/exceptions" ) @@ -133,6 +134,7 @@ func ServiceRegistry() *service.Registry { resolved.RegisterService(registry) ssmapi.RegisterService(registry) + xboard.RegisterService(registry) registerDERPService(registry) registerCCMService(registry) diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..c109504b --- /dev/null +++ b/install.sh @@ -0,0 +1,201 @@ +#!/bin/bash + +# sing-box Xboard Integration Installation Script +# This script automates the installation and configuration of sing-box with Xboard support. + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Configuration +CONFIG_DIR="/etc/sing-box" +CONFIG_FILE="$CONFIG_DIR/config.json" +BINARY_PATH="/usr/local/bin/sing-box" +SERVICE_FILE="/etc/systemd/system/sing-box.service" + +echo -e "${GREEN}Welcome to sing-box Xboard Installation Script${NC}" + +# Check root +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}This script must be run as root${NC}" + exit 1 +fi + +# Detect Architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) BINARY_ARCH="amd64" ;; + aarch64) BINARY_ARCH="arm64" ;; + *) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;; +esac + +# Interactive Prompts +read -p "Enter Panel URL (e.g., https://yourbase.com): " PANEL_URL +read -p "Enter Node ID: " NODE_ID +read -p "Enter Panel Token (Node Key): " PANEL_TOKEN + +if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then + echo -e "${RED}All fields are required!${NC}" + exit 1 +fi + +# Clean up trailing slash +PANEL_URL="${PANEL_URL%/}" + +# Prepare directories +mkdir -p "$CONFIG_DIR" +mkdir -p "/var/lib/sing-box" + +# Check and Install Go +install_go() { + echo -e "${YELLOW}Checking Go environment...${NC}" + if command -v go >/dev/null 2>&1; then + GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//' | cut -d. -f1,2) + if [[ "$(printf '%s\n' "1.24" "$GO_VERSION" | sort -V | head -n1)" == "1.24" ]]; then + echo -e "${GREEN}Go $GO_VERSION already installed.${NC}" + return + fi + fi + + echo -e "${YELLOW}Installing Go 1.24.7...${NC}" + GO_TAR="go1.24.7.linux-$BINARY_ARCH.tar.gz" + curl -L "https://golang.org/dl/$GO_TAR" -o "$GO_TAR" + rm -rf /usr/local/go && tar -C /usr/local -xzf "$GO_TAR" + rm "$GO_TAR" + + # Add to PATH for current session + export PATH=$PATH:/usr/local/go/bin + if ! grep -q "/usr/local/go/bin" ~/.bashrc; then + echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc + fi + echo -e "${GREEN}Go installed successfully.${NC}" +} + +# Build sing-box +build_sing_box() { + echo -e "${YELLOW}Building sing-box from source...${NC}" + + # Check if we are in the source directory + if [[ ! -f "go.mod" ]]; then + echo -e "${YELLOW}Source not found in current directory. Cloning repository...${NC}" + if ! command -v git >/dev/null 2>&1; then + echo -e "${YELLOW}Installing git...${NC}" + apt-get update && apt-get install -y git || yum install -y git + fi + # Replace with your repository URL + git clone https://github.com/sagernet/sing-box.git sing-box-src + cd sing-box-src + fi + + # Build params from Makefile + VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") + TAGS="with_quic,with_shadowsocks,with_v2rayapi,with_utls,with_clash_api,with_gvisor" + + echo -e "${YELLOW}Running go build...${NC}" + go build -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" -tags "$TAGS" ./cmd/sing-box + + mv sing-box "$BINARY_PATH" + chmod +x "$BINARY_PATH" + echo -e "${GREEN}sing-box built and installed to $BINARY_PATH${NC}" +} + +install_go +build_sing_box + +# Generate Configuration +echo -e "${YELLOW}Generating configuration...${NC}" +cat > "$CONFIG_FILE" < "$SERVICE_FILE" < 0 { + paddingScheme = []byte(strings.Join(h.options.PaddingScheme, "\n")) + } + + anytlsUsers := make([]anytls.User, len(users)) + for i := range users { + anytlsUsers[i] = anytls.User{ + Name: users[i], + Password: passwords[i], + } + } + + service, err := anytls.NewService(anytls.ServiceConfig{ + Users: anytlsUsers, + PaddingScheme: paddingScheme, + Handler: (*inboundHandler)(h), + Logger: h.logger, + }) + if err != nil { + return err + } + h.service = service + return nil } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { @@ -42,6 +84,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag), router: uot.NewRouter(router, logger), logger: logger, + options: options, } if options.TLS != nil && options.TLS.Enabled { @@ -106,7 +149,14 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } - err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) + h.ssmMutex.RLock() + tracker := h.tracker + service := h.service + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } + err := service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go index 7ff92646..fb0b1cae 100644 --- a/protocol/shadowsocks/inbound_multi.go +++ b/protocol/shadowsocks/inbound_multi.go @@ -122,7 +122,7 @@ func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { h.tracker = tracker } -func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { +func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string, flows []string) error { err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { return index }), uPSKs) diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 75cd4124..53322cc0 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -43,6 +44,45 @@ type Inbound struct { service *vless.Service[int] tlsConfig tls.ServerConfig transport adapter.V2RayServerTransport + tracker adapter.SSMTracker + ssmMutex sync.RWMutex +} + +var _ adapter.ManagedSSMServer = (*Inbound)(nil) + +func (h *Inbound) SetTracker(tracker adapter.SSMTracker) { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + h.tracker = tracker +} + +func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + newUsers := make([]option.VLESSUser, len(users)) + for i := range users { + flow := "" + if i < len(flows) { + flow = flows[i] + } + if flow == "" { + flow = "xtls-rprx-vision" + } + newUsers[i] = option.VLESSUser{ + Name: users[i], + UUID: uuids[i], + Flow: flow, + } + } + h.users = newUsers + h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(h.users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(h.users, func(it option.VLESSUser) string { + return it.Flow + })) + return nil } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -157,6 +197,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) @@ -203,6 +249,12 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) + } h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 4e9c763c..e3f4a388 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "sync" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -44,6 +45,36 @@ type Inbound struct { users []option.VMessUser tlsConfig tls.ServerConfig transport adapter.V2RayServerTransport + tracker adapter.SSMTracker + ssmMutex sync.RWMutex +} + +var _ adapter.ManagedSSMServer = (*Inbound)(nil) + +func (h *Inbound) SetTracker(tracker adapter.SSMTracker) { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + h.tracker = tracker +} + +func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error { + h.ssmMutex.Lock() + defer h.ssmMutex.Unlock() + newUsers := make([]option.VMessUser, len(users)) + for i := range users { + newUsers[i] = option.VMessUser{ + Name: users[i], + UUID: uuids[i], + } + } + h.users = newUsers + return h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, it option.VMessUser) int { + return index + }), common.Map(h.users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(h.users, func(it option.VMessUser) int { + return it.AlterId + })) } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) { @@ -163,6 +194,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } conn = tlsConn } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) + } err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) if err != nil { N.CloseOnHandshakeFailure(conn, onClose, err) @@ -209,6 +246,12 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } + h.ssmMutex.RLock() + tracker := h.tracker + h.ssmMutex.RUnlock() + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) + } h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) } diff --git a/service/ssmapi/user.go b/service/ssmapi/user.go index 26bc621a..21aba15c 100644 --- a/service/ssmapi/user.go +++ b/service/ssmapi/user.go @@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error { users = append(users, username) uPSKs = append(uPSKs, password) } - err := m.server.UpdateUsers(users, uPSKs) + err := m.server.UpdateUsers(users, uPSKs, nil) if err != nil { return err } diff --git a/service/xboard/service.go b/service/xboard/service.go new file mode 100644 index 00000000..05c2d048 --- /dev/null +++ b/service/xboard/service.go @@ -0,0 +1,294 @@ +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" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "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 + 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 + access sync.Mutex +} + +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)), + } + + 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) + } + + inboundManager := service.FromContext[adapter.InboundManager](ctx) + allInbounds := inboundManager.List() + for _, inbound := range allInbounds { + managedServer, isManaged := inbound.(adapter.ManagedSSMServer) + if isManaged { + traffic := ssmapi.NewTrafficManager() + managedServer.SetTracker(traffic) + user := ssmapi.NewUserManager(managedServer, traffic) + s.traffics[inbound.Tag()] = traffic + s.users[inbound.Tag()] = user + s.servers[inbound.Tag()] = managedServer + s.inboundTags = append(s.inboundTags, inbound.Tag()) + } + } + + if len(s.inboundTags) == 0 { + logger.Warn("Xboard service: no managed inbounds found") + } + + return s, nil +} + +func (s *Service) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + go s.loop() + return 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() + } + } +} + +type userData struct { + ID int + Email string + Key string + Flow string +} + +func (s *Service) syncUsers() { + s.logger.Info("Xboard sync users...") + xUsers, 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 xUsers { + key := u.UUID + if key == "" { + key = u.Passwd + } + 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(xUsers)) +} + +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 { + url := fmt.Sprintf("%s/api/v1/server/UniProxy/push?node_id=%d", s.options.PanelURL, s.options.NodeID) + body, _ := json.Marshal(data) + + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body)) + req.Header.Set("Authorization", s.options.Key) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return E.New("failed to push traffic, status: ", resp.Status) + } + return nil +} + +func (s *Service) Close() error { + s.cancel() + s.syncTicker.Stop() + s.reportTicker.Stop() + return nil +} + +// Xboard User Model +type XUser struct { + ID int `json:"id"` + Email string `json:"email"` + UUID string `json:"uuid"` + Passwd string `json:"passwd"` + Flow string `json:"flow"` +} + +func (s *Service) fetchUsers() ([]XUser, error) { + url := fmt.Sprintf("%s/api/v1/server/UniProxy/user?node_id=%d", s.options.PanelURL, s.options.NodeID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", s.options.Key) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, E.New("failed to fetch users, status: ", resp.Status) + } + + var result struct { + Data []XUser `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return nil, err + } + return result.Data, nil +}