多节点聚合

This commit is contained in:
CN-JS-HuiBai
2026-04-15 11:51:34 +08:00
parent c3a2e85266
commit 25d939e3a7
6 changed files with 436 additions and 80 deletions

View File

@@ -123,32 +123,106 @@ fi
read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL
PANEL_URL=${INPUT_URL:-$PANEL_URL}
read -p "Enter Node ID [${NODE_ID}]: " INPUT_ID
NODE_ID=${INPUT_ID:-$NODE_ID}
read -p "Enter Node Type (e.g., v2ray) [${NODE_TYPE:-v2ray}]: " INPUT_TYPE
NODE_TYPE=${INPUT_TYPE:-${NODE_TYPE:-v2ray}}
read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN
PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN}
# Consolidation
CONFIG_PANEL_URL=$PANEL_URL
CONFIG_NODE_ID=$NODE_ID
USER_PANEL_URL=$PANEL_URL
USER_NODE_ID=$NODE_ID
read -p "Enter Node Count [${NODE_COUNT:-1}]: " INPUT_COUNT
NODE_COUNT=${INPUT_COUNT:-${NODE_COUNT:-1}}
if ! [[ "$NODE_COUNT" =~ ^[0-9]+$ ]] || [[ "$NODE_COUNT" -lt 1 ]]; then
echo -e "${RED}Node Count must be a positive integer${NC}"
exit 1
fi
declare -a NODE_IDS
declare -a NODE_TYPES
declare -a NODE_TAGS
for ((i=1; i<=NODE_COUNT; i++)); do
DEFAULT_NODE_ID=""
DEFAULT_NODE_TYPE="${NODE_TYPE:-vless}"
DEFAULT_NODE_TAG=""
if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then
DEFAULT_NODE_ID="$NODE_ID"
fi
read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}]: " INPUT_ID
CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID}
if [[ -z "$CURRENT_NODE_ID" ]]; then
echo -e "${RED}Node ID is required for node #$i${NC}"
exit 1
fi
read -p "Enter Node Type for node #$i [${DEFAULT_NODE_TYPE}]: " INPUT_TYPE
CURRENT_NODE_TYPE=${INPUT_TYPE:-$DEFAULT_NODE_TYPE}
read -p "Enter Tag for node #$i (optional) [${DEFAULT_NODE_TAG}]: " INPUT_TAG
CURRENT_NODE_TAG=${INPUT_TAG:-$DEFAULT_NODE_TAG}
NODE_IDS+=("$CURRENT_NODE_ID")
NODE_TYPES+=("$CURRENT_NODE_TYPE")
NODE_TAGS+=("$CURRENT_NODE_TAG")
done
# Sync time (Critical for SS 2022)
echo -e "${YELLOW}Syncing system time...${NC}"
timedatectl set-ntp true || true
if [[ -z "$PANEL_URL" || -z "$NODE_ID" || -z "$PANEL_TOKEN" ]]; then
if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then
echo -e "${RED}All fields are required!${NC}"
exit 1
fi
# Clean up trailing slash
PANEL_URL="${PANEL_URL%/}"
CONFIG_PANEL_URL=$PANEL_URL
USER_PANEL_URL=$PANEL_URL
SERVICE_JSON=$(cat <<EOF
{
"type": "xboard",
"panel_url": "$PANEL_URL",
"config_panel_url": "$CONFIG_PANEL_URL",
"user_panel_url": "$USER_PANEL_URL",
"key": "$PANEL_TOKEN",
"sync_interval": "1m",
"report_interval": "1m"
EOF
)
if [[ "$NODE_COUNT" -eq 1 ]]; then
SERVICE_JSON+=$(cat <<EOF
,
"node_id": ${NODE_IDS[0]},
"node_type": "${NODE_TYPES[0]}",
"config_node_id": ${NODE_IDS[0]},
"user_node_id": ${NODE_IDS[0]}
EOF
)
else
SERVICE_JSON+=$',
"nodes": ['
for ((i=0; i<NODE_COUNT; i++)); do
NODE_BLOCK=$(cat <<EOF
{
"node_id": ${NODE_IDS[$i]},
"config_node_id": ${NODE_IDS[$i]},
"user_node_id": ${NODE_IDS[$i]},
"node_type": "${NODE_TYPES[$i]}"
EOF
)
if [[ -n "${NODE_TAGS[$i]}" ]]; then
NODE_BLOCK+=$(cat <<EOF
,
"tag": "${NODE_TAGS[$i]}"
EOF
)
fi
NODE_BLOCK+=$'\n }'
if [[ "$i" -gt 0 ]]; then
SERVICE_JSON+=$','
fi
SERVICE_JSON+=$'\n'"$NODE_BLOCK"
done
SERVICE_JSON+=$'\n ]'
fi
SERVICE_JSON+=$'\n }'
# Generate Configuration
echo -e "${YELLOW}Generating configuration...${NC}"
@@ -175,19 +249,7 @@ cat > "$CONFIG_FILE" <<EOF
]
},
"services": [
{
"type": "xboard",
"panel_url": "$PANEL_URL",
"config_panel_url": "$CONFIG_PANEL_URL",
"user_panel_url": "$USER_PANEL_URL",
"key": "$PANEL_TOKEN",
"node_id": $NODE_ID,
"node_type": "$NODE_TYPE",
"config_node_id": ${CONFIG_NODE_ID:-0},
"user_node_id": ${USER_NODE_ID:-0},
"sync_interval": "1m",
"report_interval": "1m"
}
${SERVICE_JSON}
],
"inbounds": [],
"outbounds": [

View File

@@ -15,4 +15,19 @@ type XBoardServiceOptions struct {
NodeType string `json:"node_type"`
SyncInterval badoption.Duration `json:"sync_interval,omitempty"`
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
Nodes []XBoardNodeOptions `json:"nodes,omitempty"`
}
type XBoardNodeOptions struct {
Tag string `json:"tag,omitempty"`
PanelURL string `json:"panel_url,omitempty"`
ConfigPanelURL string `json:"config_panel_url,omitempty"`
UserPanelURL string `json:"user_panel_url,omitempty"`
Key string `json:"key,omitempty"`
NodeID int `json:"node_id,omitempty"`
ConfigNodeID int `json:"config_node_id,omitempty"`
UserNodeID int `json:"user_node_id,omitempty"`
NodeType string `json:"node_type,omitempty"`
SyncInterval badoption.Duration `json:"sync_interval,omitempty"`
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
}

View File

@@ -65,9 +65,6 @@ func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) er
if i < len(flows) {
flow = flows[i]
}
if flow == "" {
flow = "xtls-rprx-vision"
}
newUsers[i] = option.VLESSUser{
Name: users[i],
UUID: uuids[i],

View File

@@ -0,0 +1,112 @@
package xboard
import (
"context"
"fmt"
"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"
)
type multiNodeService struct {
boxService.Adapter
services []adapter.Service
}
func newMultiNodeService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) {
expanded := expandNodeOptions(options)
services := make([]adapter.Service, 0, len(expanded))
for i, node := range expanded {
nodeTag := expandedNodeTag(tag, i, options.Nodes[i], node)
service, err := newSingleService(ctx, logger, nodeTag, node)
if err != nil {
return nil, fmt.Errorf("create xboard node service %s: %w", nodeTag, err)
}
services = append(services, service)
}
return &multiNodeService{
Adapter: boxService.NewAdapter(C.TypeXBoard, tag),
services: services,
}, nil
}
func expandNodeOptions(base option.XBoardServiceOptions) []option.XBoardServiceOptions {
if len(base.Nodes) == 0 {
return []option.XBoardServiceOptions{base}
}
result := make([]option.XBoardServiceOptions, 0, len(base.Nodes))
for _, node := range base.Nodes {
child := base
child.Nodes = nil
if node.PanelURL != "" {
child.PanelURL = node.PanelURL
}
if node.ConfigPanelURL != "" {
child.ConfigPanelURL = node.ConfigPanelURL
}
if node.UserPanelURL != "" {
child.UserPanelURL = node.UserPanelURL
}
if node.Key != "" {
child.Key = node.Key
}
if node.NodeID != 0 {
child.NodeID = node.NodeID
}
if node.ConfigNodeID != 0 {
child.ConfigNodeID = node.ConfigNodeID
}
if node.UserNodeID != 0 {
child.UserNodeID = node.UserNodeID
}
if node.NodeType != "" {
child.NodeType = node.NodeType
}
if node.SyncInterval != 0 {
child.SyncInterval = node.SyncInterval
}
if node.ReportInterval != 0 {
child.ReportInterval = node.ReportInterval
}
result = append(result, child)
}
return result
}
func expandedNodeTag(baseTag string, index int, entry option.XBoardNodeOptions, node option.XBoardServiceOptions) string {
nodeTag := ""
if entry.Tag != "" {
nodeTag = entry.Tag
}
if nodeTag == "" && node.NodeID != 0 {
nodeTag = fmt.Sprintf("%d", node.NodeID)
}
if nodeTag == "" {
nodeTag = fmt.Sprintf("%d", index+1)
}
if baseTag == "" {
return "xboard-" + nodeTag
}
return fmt.Sprintf("%s-%s", baseTag, nodeTag)
}
func (s *multiNodeService) Start(stage adapter.StartStage) error {
for _, service := range s.services {
if err := service.Start(stage); err != nil {
return err
}
}
return nil
}
func (s *multiNodeService) Close() error {
for i := len(s.services) - 1; i >= 0; i-- {
if err := s.services[i].Close(); err != nil {
return err
}
}
return nil
}

View File

@@ -74,23 +74,12 @@ type Service struct {
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 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"`
@@ -107,6 +96,13 @@ type XNodeConfig struct {
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"`
@@ -124,6 +120,24 @@ type XNodeConfig struct {
// 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 {
@@ -138,6 +152,12 @@ type XInnerConfig struct {
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"`
@@ -245,6 +265,13 @@ func (s *XStreamSettings) GetReality() *XRealitySettings {
}
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),
@@ -277,6 +304,105 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
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
@@ -393,7 +519,7 @@ func (s *Service) setupNode() error {
return err
}
inboundTag := "xboard-inbound"
inboundTag := s.inboundTag()
// Resolve nested config (V2bX compatibility: server_config / serverConfig / config)
var inner XInnerConfig
@@ -472,6 +598,8 @@ func (s *Service) setupNode() error {
}
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 {
@@ -489,51 +617,53 @@ func (s *Service) setupNode() error {
// V2bX: 0=None, 1=TLS, 2=Reality
var tlsOptions option.InboundTLSOptions
securityType := inner.TLS
tlsSettings := inner.TLSSettings
if tlsSettings == nil {
tlsSettings = inner.TLSSettings_
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
tlsOptions.Enabled = true
if tlsSettings != nil {
tlsOptions.ServerName = tlsSettings.ServerName
}
tlsOptions.Enabled = hasCertificate
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)
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)
@@ -567,6 +697,10 @@ func (s *Service) setupNode() error {
}
}
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
@@ -757,6 +891,10 @@ func (s *Service) setupNode() error {
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)
@@ -920,11 +1058,16 @@ func (s *Service) syncUsers() {
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: u.Flow,
Flow: flow,
}
}

View File

@@ -1,6 +1,10 @@
package xboard
import "testing"
import (
"testing"
"github.com/sagernet/sing-box/option"
)
func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) {
user := XUser{
@@ -66,3 +70,26 @@ func TestXUserIdentifierFallsBackToEmailThenID(t *testing.T) {
t.Fatalf("Identifier() = %q, want %q", got, "9")
}
}
func TestExpandNodeOptions(t *testing.T) {
base := option.XBoardServiceOptions{
PanelURL: "https://panel.example",
Key: "shared-token",
NodeType: "vless",
Nodes: []option.XBoardNodeOptions{
{NodeID: 1},
{NodeID: 2, NodeType: "anytls"},
},
}
nodes := expandNodeOptions(base)
if len(nodes) != 2 {
t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes))
}
if nodes[0].NodeID != 1 || nodes[0].NodeType != "vless" {
t.Fatalf("first node = %+v", nodes[0])
}
if nodes[1].NodeID != 2 || nodes[1].NodeType != "anytls" {
t.Fatalf("second node = %+v", nodes[1])
}
}