diff --git a/install.sh b/install.sh index 1b127a75..aea3ee9f 100644 --- a/install.sh +++ b/install.sh @@ -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 < "$CONFIG_FILE" <= 0; i-- { + if err := s.services[i].Close(); err != nil { + return err + } + } + return nil +} diff --git a/service/xboard/service.go b/service/xboard/service.go index 8d478d90..8e4665c5 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -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, } } diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go index e7ac2e58..3b6f52cc 100644 --- a/service/xboard/service_test.go +++ b/service/xboard/service_test.go @@ -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]) + } +}