添加对于Xboard的完整支持

This commit is contained in:
CN-JS-HuiBai
2026-04-14 22:58:28 +08:00
parent b71056eea7
commit dcf4f06dab
10 changed files with 658 additions and 4 deletions

12
.config.example Normal file
View File

@@ -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"
}
]
}

View File

@@ -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 {

View File

@@ -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)

201
install.sh Normal file
View File

@@ -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" <<EOF
{
"log": {
"level": "info",
"timestamp": true
},
"experimental": {
"cache_file": {
"enabled": true,
"path": "/var/lib/sing-box/cache.db"
}
},
"services": [
{
"type": "xboard",
"panel_url": "$PANEL_URL",
"key": "$PANEL_TOKEN",
"node_id": $NODE_ID,
"sync_interval": "1m",
"report_interval": "1m"
}
],
"inbounds": [
{
"type": "vless",
"tag": "vless-in",
"listen": "::",
"listen_port": 443,
"sniff": true,
"sniff_override_destination": true,
"tls": {
"enabled": true,
"server_name": "www.google.com",
"reality": {
"enabled": true,
"handshake": {
"server": "www.google.com",
"server_port": 443
},
"private_key": "YOUR_PRIVATE_KEY",
"short_id": ["YOUR_SHORT_ID"]
}
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
}
]
}
}
EOF
echo -e "${YELLOW}NOTE: VLESS+Reality template created. Please update 'private_key' and 'short_id' in $CONFIG_FILE if you use Reality.${NC}"
# Create Systemd Service
echo -e "${YELLOW}Creating systemd service...${NC}"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=sing-box service
After=network.target nss-lookup.target
[Service]
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
ExecStart=$BINARY_PATH run -c $CONFIG_FILE
Restart=on-failure
RestartSec=10
LimitNOFILE=infinity
[Install]
WantedBy=multi-user.target
EOF
# Reload and Start
systemctl daemon-reload
echo -e "${GREEN}Service created. You can start it with: systemctl start sing-box${NC}"
echo -e "${GREEN}Check status with: systemctl status sing-box${NC}"

View File

@@ -4,6 +4,7 @@ import (
"context"
"net"
"strings"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@@ -35,6 +36,47 @@ type Inbound struct {
logger logger.ContextLogger
listener *listener.Listener
service *anytls.Service
options option.AnyTLSInboundOptions
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, passwords []string, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
paddingScheme := padding.DefaultPaddingScheme
if len(h.options.PaddingScheme) > 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))

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

294
service/xboard/service.go Normal file
View File

@@ -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
}