1781 lines
51 KiB
Go
1781 lines
51 KiB
Go
package xboard
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
// ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64.
|
|
// This matches the logic in V2bX/core/sing/user.go.
|
|
func ss2022Key(seed string, keyLen int) string {
|
|
if len(seed) > keyLen {
|
|
seed = seed[:keyLen]
|
|
} else if len(seed) < keyLen {
|
|
padded := make([]byte, keyLen)
|
|
copy(padded, []byte(seed))
|
|
seed = string(padded)
|
|
}
|
|
return base64.StdEncoding.EncodeToString([]byte(seed))
|
|
}
|
|
|
|
// ss2022KeyLength returns the required key length for a given SS2022 cipher.
|
|
func ss2022KeyLength(cipher string) int {
|
|
switch cipher {
|
|
case "2022-blake3-aes-128-gcm":
|
|
return 16
|
|
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
|
|
return 32
|
|
default:
|
|
return 32
|
|
}
|
|
}
|
|
|
|
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
|
|
aliveUsers map[string]map[string]time.Time
|
|
inboundTags []string
|
|
syncTicker *time.Ticker
|
|
reportTicker *time.Ticker
|
|
aliveTicker *time.Ticker
|
|
access sync.Mutex
|
|
router adapter.Router
|
|
inboundManager adapter.InboundManager
|
|
protocol string
|
|
vlessFlow string
|
|
vlessServerName string
|
|
ssCipher string // stored for user key derivation in syncUsers
|
|
ssServerKey string // stored for SS2022 per-user key extraction
|
|
}
|
|
|
|
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"`
|
|
ListenIP string `json:"listen_ip"`
|
|
Port int `json:"port"`
|
|
ServerPort int `json:"server_port"`
|
|
Protocol string `json:"protocol"`
|
|
Cipher string `json:"cipher"`
|
|
ServerKey string `json:"server_key"`
|
|
TLS int `json:"tls"`
|
|
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"`
|
|
DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
|
|
DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"`
|
|
TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
|
|
TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"`
|
|
TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
|
|
TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"`
|
|
AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
|
|
AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"`
|
|
Multiplex *XMultiplexConfig `json:"multiplex,omitempty"`
|
|
NetworkSettings json.RawMessage `json:"network_settings"`
|
|
NetworkSettings_ json.RawMessage `json:"networkSettings"`
|
|
|
|
// Hysteria / Hysteria2
|
|
UpMbps int `json:"up_mbps"`
|
|
DownMbps int `json:"down_mbps"`
|
|
Obfs string `json:"obfs"`
|
|
ObfsPassword string `json:"obfs-password"`
|
|
Ignore_Client_Bandwidth bool `json:"ignore_client_bandwidth"`
|
|
|
|
// Tuic
|
|
CongestionControl string `json:"congestion_control"`
|
|
ZeroRTTHandshake bool `json:"zero_rtt_handshake"`
|
|
|
|
// 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 {
|
|
ListenIP string `json:"listen_ip"`
|
|
Port int `json:"port"`
|
|
ServerPort int `json:"server_port"`
|
|
Protocol string `json:"protocol"`
|
|
NodeType string `json:"node_type"`
|
|
Cipher string `json:"cipher"`
|
|
ServerKey string `json:"server_key"`
|
|
TLS int `json:"tls"`
|
|
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"`
|
|
DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
|
|
DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"`
|
|
TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
|
|
TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"`
|
|
TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
|
|
TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"`
|
|
AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
|
|
AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"`
|
|
Multiplex *XMultiplexConfig `json:"multiplex,omitempty"`
|
|
NetworkSettings json.RawMessage `json:"network_settings"`
|
|
NetworkSettings_ json.RawMessage `json:"networkSettings"`
|
|
StreamSettings json.RawMessage `json:"streamSettings"`
|
|
UpMbps int `json:"up_mbps"`
|
|
DownMbps int `json:"down_mbps"`
|
|
}
|
|
|
|
type XMultiplexConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
MaxConnections int `json:"max_connections,omitempty"`
|
|
MinStreams int `json:"min_streams,omitempty"`
|
|
MaxStreams int `json:"max_streams,omitempty"`
|
|
Padding bool `json:"padding,omitempty"`
|
|
Brutal *XBrutalConfig `json:"brutal,omitempty"`
|
|
}
|
|
|
|
type XBrutalConfig struct {
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
UpMbps int `json:"up_mbps,omitempty"`
|
|
DownMbps int `json:"down_mbps,omitempty"`
|
|
}
|
|
|
|
type HttpNetworkConfig struct {
|
|
Header struct {
|
|
Type string `json:"type"`
|
|
Request *json.RawMessage `json:"request"`
|
|
Response *json.RawMessage `json:"response"`
|
|
} `json:"header"`
|
|
}
|
|
|
|
type HttpRequest struct {
|
|
Version string `json:"version"`
|
|
Method string `json:"method"`
|
|
Path []string `json:"path"`
|
|
Headers struct {
|
|
Host []string `json:"Host"`
|
|
} `json:"headers"`
|
|
}
|
|
|
|
type WsNetworkConfig struct {
|
|
Path string `json:"path"`
|
|
Headers map[string]string `json:"headers"`
|
|
}
|
|
|
|
type GrpcNetworkConfig struct {
|
|
ServiceName string `json:"serviceName"`
|
|
}
|
|
|
|
func unmarshalNetworkSettings(settings json.RawMessage) map[string]any {
|
|
if len(settings) == 0 {
|
|
return nil
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(settings, &raw); err != nil {
|
|
return nil
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func readNetworkString(raw map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
value, exists := raw[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if stringValue, ok := value.(string); ok && stringValue != "" {
|
|
return stringValue
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readNetworkBool(raw map[string]any, keys ...string) (bool, bool) {
|
|
for _, key := range keys {
|
|
value, exists := raw[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if boolValue, ok := value.(bool); ok {
|
|
return boolValue, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func readNetworkDuration(raw map[string]any, keys ...string) (badoption.Duration, bool) {
|
|
for _, key := range keys {
|
|
value, exists := raw[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
switch typedValue := value.(type) {
|
|
case float64:
|
|
return badoption.Duration(time.Duration(typedValue) * time.Second), true
|
|
case string:
|
|
if typedValue == "" {
|
|
continue
|
|
}
|
|
if durationValue, err := time.ParseDuration(typedValue); err == nil {
|
|
return badoption.Duration(durationValue), true
|
|
}
|
|
if secondsValue, err := strconv.ParseInt(typedValue, 10, 64); err == nil {
|
|
return badoption.Duration(time.Duration(secondsValue) * time.Second), true
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
type HttpupgradeNetworkConfig struct {
|
|
Path string `json:"path"`
|
|
Host string `json:"host"`
|
|
}
|
|
|
|
type XTLSSettings struct {
|
|
ServerName string `json:"server_name"`
|
|
ServerPort string `json:"server_port"`
|
|
PublicKey string `json:"public_key"`
|
|
PrivateKey string `json:"private_key"`
|
|
ShortID string `json:"short_id"`
|
|
ShortIDs []string `json:"short_ids"`
|
|
AllowInsecure bool `json:"allow_insecure"`
|
|
Dest string `json:"dest"`
|
|
}
|
|
|
|
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) {
|
|
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),
|
|
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),
|
|
aliveUsers: make(map[string]map[string]time.Time),
|
|
syncTicker: time.NewTicker(time.Duration(options.SyncInterval)),
|
|
reportTicker: time.NewTicker(time.Duration(options.ReportInterval)),
|
|
aliveTicker: time.NewTicker(1 * time.Minute),
|
|
router: service.FromContext[adapter.Router](ctx),
|
|
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) 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 buildInboundMultiplex(config *XMultiplexConfig) *option.InboundMultiplexOptions {
|
|
if config == nil || !config.Enabled {
|
|
return nil
|
|
}
|
|
var brutal *option.BrutalOptions
|
|
if config.Brutal != nil {
|
|
brutal = &option.BrutalOptions{
|
|
Enabled: config.Brutal.Enabled,
|
|
UpMbps: config.Brutal.UpMbps,
|
|
DownMbps: config.Brutal.DownMbps,
|
|
}
|
|
}
|
|
return &option.InboundMultiplexOptions{
|
|
Enabled: config.Enabled,
|
|
Padding: config.Padding,
|
|
Brutal: brutal,
|
|
}
|
|
}
|
|
|
|
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 getInboundTransport(network string, settings json.RawMessage) (*option.V2RayTransportOptions, error) {
|
|
if network == "" {
|
|
return nil, nil
|
|
}
|
|
t := &option.V2RayTransportOptions{
|
|
Type: network,
|
|
}
|
|
rawSettings := unmarshalNetworkSettings(settings)
|
|
switch network {
|
|
case "tcp":
|
|
if len(settings) != 0 {
|
|
var networkConfig HttpNetworkConfig
|
|
err := json.Unmarshal(settings, &networkConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode NetworkSettings error: %s", err)
|
|
}
|
|
if networkConfig.Header.Type == "http" {
|
|
t.Type = networkConfig.Header.Type
|
|
if networkConfig.Header.Request != nil {
|
|
var request HttpRequest
|
|
err = json.Unmarshal(*networkConfig.Header.Request, &request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode HttpRequest error: %s", err)
|
|
}
|
|
t.HTTPOptions.Host = request.Headers.Host
|
|
if len(request.Path) > 0 {
|
|
t.HTTPOptions.Path = request.Path[0]
|
|
}
|
|
t.HTTPOptions.Method = request.Method
|
|
}
|
|
} else {
|
|
t.Type = ""
|
|
}
|
|
} else {
|
|
t.Type = ""
|
|
}
|
|
case "ws":
|
|
var (
|
|
path string
|
|
ed int
|
|
headers map[string]badoption.Listable[string]
|
|
)
|
|
if len(settings) != 0 {
|
|
var networkConfig WsNetworkConfig
|
|
err := json.Unmarshal(settings, &networkConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode NetworkSettings error: %s", err)
|
|
}
|
|
u, err := url.Parse(networkConfig.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse WS path error: %s", err)
|
|
}
|
|
path = u.Path
|
|
ed, _ = strconv.Atoi(u.Query().Get("ed"))
|
|
if len(networkConfig.Headers) > 0 {
|
|
headers = make(map[string]badoption.Listable[string], len(networkConfig.Headers))
|
|
for k, v := range networkConfig.Headers {
|
|
headers[k] = badoption.Listable[string]{v}
|
|
}
|
|
}
|
|
}
|
|
t.WebsocketOptions = option.V2RayWebsocketOptions{
|
|
Path: path,
|
|
EarlyDataHeaderName: "Sec-WebSocket-Protocol",
|
|
MaxEarlyData: uint32(ed),
|
|
Headers: headers,
|
|
}
|
|
case "grpc":
|
|
if len(settings) != 0 {
|
|
var networkConfig GrpcNetworkConfig
|
|
err := json.Unmarshal(settings, &networkConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode gRPC settings error: %s", err)
|
|
}
|
|
t.GRPCOptions = option.V2RayGRPCOptions{
|
|
ServiceName: networkConfig.ServiceName,
|
|
}
|
|
if serviceName := readNetworkString(rawSettings, "service_name"); serviceName != "" && t.GRPCOptions.ServiceName == "" {
|
|
t.GRPCOptions.ServiceName = serviceName
|
|
}
|
|
if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok {
|
|
t.GRPCOptions.IdleTimeout = idleTimeout
|
|
}
|
|
if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok {
|
|
t.GRPCOptions.PingTimeout = pingTimeout
|
|
}
|
|
if permitWithoutStream, ok := readNetworkBool(rawSettings, "permit_without_stream", "permitWithoutStream"); ok {
|
|
t.GRPCOptions.PermitWithoutStream = permitWithoutStream
|
|
}
|
|
}
|
|
case "httpupgrade":
|
|
if len(settings) != 0 {
|
|
var networkConfig HttpupgradeNetworkConfig
|
|
err := json.Unmarshal(settings, &networkConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode HttpUpgrade settings error: %s", err)
|
|
}
|
|
t.HTTPUpgradeOptions = option.V2RayHTTPUpgradeOptions{
|
|
Path: networkConfig.Path,
|
|
Host: networkConfig.Host,
|
|
}
|
|
}
|
|
case "h2", "http":
|
|
t.Type = "http"
|
|
if rawSettings != nil {
|
|
t.HTTPOptions = option.V2RayHTTPOptions{
|
|
Path: readNetworkString(rawSettings, "path"),
|
|
Method: readNetworkString(rawSettings, "method"),
|
|
Headers: nil,
|
|
}
|
|
if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok {
|
|
t.HTTPOptions.IdleTimeout = idleTimeout
|
|
}
|
|
if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok {
|
|
t.HTTPOptions.PingTimeout = pingTimeout
|
|
}
|
|
if hostValue, exists := rawSettings["host"]; exists {
|
|
switch typedHost := hostValue.(type) {
|
|
case string:
|
|
if typedHost != "" {
|
|
t.HTTPOptions.Host = badoption.Listable[string]{typedHost}
|
|
}
|
|
case []any:
|
|
hostList := make(badoption.Listable[string], 0, len(typedHost))
|
|
for _, item := range typedHost {
|
|
if host, ok := item.(string); ok && host != "" {
|
|
hostList = append(hostList, host)
|
|
}
|
|
}
|
|
if len(hostList) > 0 {
|
|
t.HTTPOptions.Host = hostList
|
|
}
|
|
}
|
|
}
|
|
if headersValue, exists := rawSettings["headers"]; exists {
|
|
if headersMap, ok := headersValue.(map[string]any); ok {
|
|
headerOptions := make(badoption.HTTPHeader)
|
|
for headerKey, headerValue := range headersMap {
|
|
switch typedHeader := headerValue.(type) {
|
|
case string:
|
|
if typedHeader != "" {
|
|
headerOptions[headerKey] = badoption.Listable[string]{typedHeader}
|
|
}
|
|
case []any:
|
|
values := make(badoption.Listable[string], 0, len(typedHeader))
|
|
for _, item := range typedHeader {
|
|
if headerString, ok := item.(string); ok && headerString != "" {
|
|
values = append(values, headerString)
|
|
}
|
|
}
|
|
if len(values) > 0 {
|
|
headerOptions[headerKey] = values
|
|
}
|
|
}
|
|
}
|
|
if len(headerOptions) > 0 {
|
|
t.HTTPOptions.Headers = headerOptions
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func networkAcceptProxyProtocol(settings json.RawMessage) bool {
|
|
if len(settings) == 0 {
|
|
return false
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(settings, &raw); err != nil {
|
|
return false
|
|
}
|
|
for _, key := range []string{"acceptProxyProtocol", "accept_proxy_protocol"} {
|
|
value, exists := raw[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
enabled, ok := value.(bool)
|
|
if ok && enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func acceptProxyProtocolEnabled(inner XInnerConfig, config *XNodeConfig) bool {
|
|
if inner.AcceptProxyProtocol || inner.AcceptProxyProtocol_ {
|
|
return true
|
|
}
|
|
if config != nil && (config.AcceptProxyProtocol || config.AcceptProxyProtocol_) {
|
|
return true
|
|
}
|
|
if networkAcceptProxyProtocol(inner.NetworkSettings) || networkAcceptProxyProtocol(inner.NetworkSettings_) {
|
|
return true
|
|
}
|
|
if config != nil && (networkAcceptProxyProtocol(config.NetworkSettings) || networkAcceptProxyProtocol(config.NetworkSettings_)) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const (
|
|
defaultXboardTCPKeepAlive = 30 * time.Second
|
|
defaultXboardTCPKeepAliveInterval = 15 * time.Second
|
|
)
|
|
|
|
func resolveTCPKeepAlive(inner XInnerConfig, config *XNodeConfig, settings json.RawMessage) (bool, badoption.Duration, badoption.Duration) {
|
|
disableKeepAlive := inner.DisableTCPKeepAlive || inner.DisableTCPKeepAlive_
|
|
if !disableKeepAlive && config != nil {
|
|
disableKeepAlive = config.DisableTCPKeepAlive || config.DisableTCPKeepAlive_
|
|
}
|
|
if !disableKeepAlive {
|
|
if networkDisable, ok := readNetworkBool(unmarshalNetworkSettings(settings), "disable_tcp_keep_alive", "disableTcpKeepAlive"); ok {
|
|
disableKeepAlive = networkDisable
|
|
}
|
|
}
|
|
if disableKeepAlive {
|
|
return true, 0, 0
|
|
}
|
|
|
|
keepAlive := inner.TCPKeepAlive
|
|
if keepAlive == 0 {
|
|
keepAlive = inner.TCPKeepAlive_
|
|
}
|
|
if keepAlive == 0 && config != nil {
|
|
keepAlive = config.TCPKeepAlive
|
|
}
|
|
if keepAlive == 0 && config != nil {
|
|
keepAlive = config.TCPKeepAlive_
|
|
}
|
|
if keepAlive == 0 {
|
|
if networkKeepAlive, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive", "tcpKeepAlive"); ok {
|
|
keepAlive = networkKeepAlive
|
|
}
|
|
}
|
|
if keepAlive == 0 {
|
|
keepAlive = badoption.Duration(defaultXboardTCPKeepAlive)
|
|
}
|
|
|
|
keepAliveInterval := inner.TCPKeepAliveInterval
|
|
if keepAliveInterval == 0 {
|
|
keepAliveInterval = inner.TCPKeepAliveInterval_
|
|
}
|
|
if keepAliveInterval == 0 && config != nil {
|
|
keepAliveInterval = config.TCPKeepAliveInterval
|
|
}
|
|
if keepAliveInterval == 0 && config != nil {
|
|
keepAliveInterval = config.TCPKeepAliveInterval_
|
|
}
|
|
if keepAliveInterval == 0 {
|
|
if networkKeepAliveInterval, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive_interval", "tcpKeepAliveInterval"); ok {
|
|
keepAliveInterval = networkKeepAliveInterval
|
|
}
|
|
}
|
|
if keepAliveInterval == 0 {
|
|
keepAliveInterval = badoption.Duration(defaultXboardTCPKeepAliveInterval)
|
|
}
|
|
|
|
return false, keepAlive, keepAliveInterval
|
|
}
|
|
|
|
func (s *Service) setupNode() error {
|
|
s.logger.Info("Xboard fetching node config...")
|
|
config, err := s.fetchConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
inboundTag := s.inboundTag()
|
|
|
|
// Resolve nested config (V2bX compatibility: server_config / serverConfig / 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 flat fields from top-level config to inner
|
|
if inner.ListenIP == "" {
|
|
inner.ListenIP = config.ListenIP
|
|
}
|
|
if inner.ListenIP == "" {
|
|
inner.ListenIP = "0.0.0.0"
|
|
}
|
|
if inner.TLSSettings == nil {
|
|
inner.TLSSettings = config.TLSSettings
|
|
}
|
|
if inner.TLSSettings == nil {
|
|
inner.TLSSettings = config.TLSSettings_
|
|
}
|
|
if inner.TLSSettings_ == nil {
|
|
inner.TLSSettings_ = config.TLSSettings_
|
|
}
|
|
if inner.TLS == 0 {
|
|
inner.TLS = config.TLS
|
|
}
|
|
if inner.Flow == "" {
|
|
inner.Flow = config.Flow
|
|
}
|
|
if inner.Protocol == "" {
|
|
inner.Protocol = config.Protocol
|
|
}
|
|
if inner.Protocol == "" {
|
|
inner.Protocol = config.NodeType
|
|
}
|
|
portSource := ""
|
|
if inner.Port == 0 {
|
|
if inner.ServerPort != 0 {
|
|
inner.Port = inner.ServerPort
|
|
portSource = "inner.server_port"
|
|
} else if config.Port != 0 {
|
|
inner.Port = config.Port
|
|
portSource = "config.port"
|
|
} else {
|
|
inner.Port = config.ServerPort
|
|
if config.ServerPort != 0 {
|
|
portSource = "config.server_port"
|
|
}
|
|
}
|
|
} else {
|
|
portSource = "inner.port"
|
|
}
|
|
if inner.Cipher == "" {
|
|
inner.Cipher = config.Cipher
|
|
}
|
|
if inner.ServerKey == "" {
|
|
inner.ServerKey = config.ServerKey
|
|
}
|
|
if inner.Network == "" {
|
|
inner.Network = config.Network
|
|
}
|
|
if inner.Multiplex == nil {
|
|
inner.Multiplex = config.Multiplex
|
|
}
|
|
if len(inner.NetworkSettings) == 0 {
|
|
inner.NetworkSettings = config.NetworkSettings
|
|
}
|
|
if len(inner.NetworkSettings_) == 0 {
|
|
inner.NetworkSettings_ = config.NetworkSettings_
|
|
}
|
|
|
|
// Resolve protocol
|
|
protocol := inner.Protocol
|
|
if protocol == "" {
|
|
protocol = config.Protocol
|
|
}
|
|
if protocol == "" {
|
|
protocol = inner.NodeType
|
|
}
|
|
if protocol == "" {
|
|
protocol = config.NodeType
|
|
}
|
|
if protocol == "" && inner.Cipher != "" {
|
|
// Fallback for shadowsocks where protocol might be missing but cipher is present
|
|
protocol = "shadowsocks"
|
|
}
|
|
|
|
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")
|
|
}
|
|
if inner.Port == 0 {
|
|
return fmt.Errorf("missing listen port for protocol %s", protocol)
|
|
}
|
|
|
|
s.logger.Info("Xboard protocol identified: ", protocol)
|
|
s.protocol = protocol
|
|
s.vlessFlow = ""
|
|
s.vlessServerName = ""
|
|
|
|
var listenAddr badoption.Addr
|
|
if addr, err := netip.ParseAddr(inner.ListenIP); err == nil {
|
|
listenAddr = badoption.Addr(addr)
|
|
} else {
|
|
listenAddr = badoption.Addr(netip.IPv4Unspecified())
|
|
}
|
|
tcpNetworkSettings := inner.NetworkSettings
|
|
if len(tcpNetworkSettings) == 0 {
|
|
tcpNetworkSettings = inner.NetworkSettings_
|
|
}
|
|
|
|
listen := option.ListenOptions{
|
|
Listen: &listenAddr,
|
|
ListenPort: uint16(inner.Port),
|
|
}
|
|
disableTCPKeepAlive, tcpKeepAlive, tcpKeepAliveInterval := resolveTCPKeepAlive(inner, config, tcpNetworkSettings)
|
|
listen.DisableTCPKeepAlive = disableTCPKeepAlive
|
|
if !disableTCPKeepAlive {
|
|
listen.TCPKeepAlive = tcpKeepAlive
|
|
listen.TCPKeepAliveInterval = tcpKeepAliveInterval
|
|
s.logger.Info(
|
|
"Xboard TCP keepalive configured. idle=", time.Duration(tcpKeepAlive),
|
|
", interval=", time.Duration(tcpKeepAliveInterval),
|
|
)
|
|
} else {
|
|
s.logger.Warn("Xboard TCP keepalive disabled by panel config")
|
|
}
|
|
if acceptProxyProtocolEnabled(inner, config) {
|
|
listen.ProxyProtocol = true
|
|
s.logger.Info("Xboard PROXY protocol enabled for inbound on ", inner.ListenIP, ":", inner.Port)
|
|
}
|
|
|
|
// ── TLS / Reality handling (matching V2bX panel.Security constants) ──
|
|
// V2bX: 0=None, 1=TLS, 2=Reality
|
|
var tlsOptions option.InboundTLSOptions
|
|
securityType := inner.TLS
|
|
tlsSettings := mergedTLSSettings(inner, config)
|
|
if tlsSettings != nil && tlsSettings.ServerName != "" {
|
|
s.vlessServerName = tlsSettings.ServerName
|
|
}
|
|
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
|
|
if tlsSettings != nil {
|
|
tlsOptions.ServerName = tlsSettings.ServerName
|
|
}
|
|
tlsOptions.Enabled = hasCertificate
|
|
case 2: // Reality
|
|
tlsOptions.Enabled = true
|
|
tlsOptions.ServerName = tlsSettings.ServerName
|
|
if tlsSettings.ServerName == "" {
|
|
s.logger.Warn("Xboard REALITY server_name is empty; clients may fail validation")
|
|
}
|
|
if tlsSettings.PrivateKey == "" {
|
|
s.logger.Warn("Xboard REALITY private_key is empty")
|
|
}
|
|
shortIDs := tlsSettings.ShortIDs
|
|
if len(shortIDs) == 0 && tlsSettings.ShortID != "" {
|
|
shortIDs = []string{tlsSettings.ShortID}
|
|
}
|
|
if len(shortIDs) == 0 {
|
|
s.logger.Warn("Xboard REALITY short_id is empty; falling back to empty short_id")
|
|
}
|
|
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. server_name=", tlsSettings.ServerName,
|
|
", dest=", dest,
|
|
", server_port=", serverPort,
|
|
", short_id_count=", len(shortIDs),
|
|
)
|
|
}
|
|
|
|
// Also check streamSettings for Reality (legacy Xboard format)
|
|
if inner.StreamSettings != nil && securityType == 0 {
|
|
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]
|
|
}
|
|
tlsOptions = 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()),
|
|
},
|
|
}
|
|
securityType = 2
|
|
s.logger.Info("Xboard REALITY config from streamSettings")
|
|
}
|
|
}
|
|
|
|
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
|
|
if len(networkSettings) == 0 {
|
|
networkSettings = inner.NetworkSettings_
|
|
}
|
|
|
|
// ── Build inbound per protocol (matching V2bX core/sing/node.go) ──
|
|
multiplex := buildInboundMultiplex(inner.Multiplex)
|
|
s.logger.Info(
|
|
"Xboard node config resolved. protocol=", protocol,
|
|
", listen_ip=", inner.ListenIP,
|
|
", listen_port=", inner.Port,
|
|
" (source=", portSource, ")",
|
|
", inner_server_port=", inner.ServerPort,
|
|
", config_port=", config.Port,
|
|
", config_server_port=", config.ServerPort,
|
|
", network=", networkType,
|
|
", tls=", securityType,
|
|
", multiplex=", multiplex != nil,
|
|
)
|
|
|
|
var inboundOptions any
|
|
switch protocol {
|
|
case "vmess", "vless":
|
|
// Transport for vmess/vless
|
|
transport, err := getInboundTransport(networkType, networkSettings)
|
|
if err != nil {
|
|
return fmt.Errorf("build transport for %s: %w", protocol, err)
|
|
}
|
|
|
|
if protocol == "vless" {
|
|
if tlsSettings != nil && tlsSettings.ServerName != "" {
|
|
s.logger.Info("Xboard VLESS server_name from panel: ", tlsSettings.ServerName)
|
|
}
|
|
resolvedFlow := inner.Flow
|
|
if resolvedFlow == "xtls-rprx-vision" {
|
|
if !tlsOptions.Enabled || (transport != nil && transport.Type != "") {
|
|
s.logger.Warn("Xboard VLESS flow xtls-rprx-vision ignored because inbound is not raw TLS/REALITY over TCP")
|
|
resolvedFlow = ""
|
|
}
|
|
}
|
|
s.vlessFlow = resolvedFlow
|
|
opts := &option.VLESSInboundOptions{
|
|
ListenOptions: listen,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &tlsOptions,
|
|
},
|
|
Multiplex: multiplex,
|
|
}
|
|
if transport != nil {
|
|
opts.Transport = transport
|
|
}
|
|
inboundOptions = opts
|
|
} else {
|
|
opts := &option.VMessInboundOptions{
|
|
ListenOptions: listen,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &tlsOptions,
|
|
},
|
|
Multiplex: multiplex,
|
|
}
|
|
if transport != nil {
|
|
opts.Transport = transport
|
|
}
|
|
inboundOptions = opts
|
|
}
|
|
case "shadowsocks":
|
|
method := inner.Cipher
|
|
serverKey := inner.ServerKey
|
|
if serverKey == "" {
|
|
serverKey = s.options.Key
|
|
}
|
|
if method == "" {
|
|
method = "aes-256-gcm"
|
|
}
|
|
|
|
// Store cipher for user key derivation in syncUsers
|
|
s.ssCipher = method
|
|
s.ssServerKey = serverKey
|
|
|
|
// V2bX approach: use server_key from panel DIRECTLY as PSK
|
|
// The panel provides it already in the correct format (base64 for 2022)
|
|
ssOptions := &option.ShadowsocksInboundOptions{
|
|
ListenOptions: listen,
|
|
Method: method,
|
|
Multiplex: multiplex,
|
|
}
|
|
|
|
isSS2022 := strings.Contains(method, "2022")
|
|
var dummyKey string
|
|
if isSS2022 {
|
|
ssOptions.Password = serverKey
|
|
dummyBytes := make([]byte, ss2022KeyLength(method))
|
|
for i := range dummyBytes {
|
|
dummyBytes[i] = byte(rand.Intn(256))
|
|
}
|
|
dummyKey = base64.StdEncoding.EncodeToString(dummyBytes)
|
|
} else {
|
|
dummyKey = "dummy_user_key"
|
|
}
|
|
|
|
ssOptions.Users = []option.ShadowsocksUser{{
|
|
Password: dummyKey,
|
|
}}
|
|
|
|
if isSS2022 {
|
|
s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password)
|
|
} else {
|
|
// Legacy SS: password-based
|
|
ssOptions.Password = serverKey
|
|
s.logger.Info("Xboard Shadowsocks setup. Method: ", method)
|
|
}
|
|
|
|
inboundOptions = ssOptions
|
|
case "trojan":
|
|
// Trojan supports ws/grpc transport like V2bX
|
|
transport, err := getInboundTransport(networkType, networkSettings)
|
|
if err != nil {
|
|
return fmt.Errorf("build transport for trojan: %w", err)
|
|
}
|
|
|
|
opts := &option.TrojanInboundOptions{
|
|
ListenOptions: listen,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &tlsOptions,
|
|
},
|
|
Multiplex: multiplex,
|
|
}
|
|
if transport != nil {
|
|
opts.Transport = transport
|
|
}
|
|
inboundOptions = opts
|
|
case "tuic":
|
|
// V2bX: TUIC always uses TLS with h3 ALPN
|
|
tuicTLS := tlsOptions
|
|
tuicTLS.Enabled = true
|
|
tuicTLS.ALPN = append(tuicTLS.ALPN, "h3")
|
|
|
|
congestionControl := config.CongestionControl
|
|
if congestionControl == "" {
|
|
congestionControl = "bbr"
|
|
}
|
|
|
|
opts := &option.TUICInboundOptions{
|
|
ListenOptions: listen,
|
|
CongestionControl: congestionControl,
|
|
ZeroRTTHandshake: config.ZeroRTTHandshake,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &tuicTLS,
|
|
},
|
|
}
|
|
inboundOptions = opts
|
|
s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl)
|
|
case "hysteria":
|
|
// V2bX: Hysteria always uses TLS
|
|
hyTLS := tlsOptions
|
|
hyTLS.Enabled = true
|
|
|
|
opts := &option.HysteriaInboundOptions{
|
|
ListenOptions: listen,
|
|
UpMbps: config.UpMbps,
|
|
DownMbps: config.DownMbps,
|
|
Obfs: config.Obfs,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &hyTLS,
|
|
},
|
|
}
|
|
inboundOptions = opts
|
|
s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps)
|
|
case "hysteria2":
|
|
// V2bX: Hysteria2 always uses TLS, optional obfs
|
|
hy2TLS := tlsOptions
|
|
hy2TLS.Enabled = true
|
|
|
|
var obfs *option.Hysteria2Obfs
|
|
if config.Obfs != "" && config.ObfsPassword != "" {
|
|
obfs = &option.Hysteria2Obfs{
|
|
Type: config.Obfs,
|
|
Password: config.ObfsPassword,
|
|
}
|
|
} else if config.Obfs != "" {
|
|
// V2bX compat: if only obfs type given, treat as salamander with obfs as password
|
|
obfs = &option.Hysteria2Obfs{
|
|
Type: "salamander",
|
|
Password: config.Obfs,
|
|
}
|
|
}
|
|
|
|
opts := &option.Hysteria2InboundOptions{
|
|
ListenOptions: listen,
|
|
UpMbps: config.UpMbps,
|
|
DownMbps: config.DownMbps,
|
|
IgnoreClientBandwidth: config.Ignore_Client_Bandwidth,
|
|
Obfs: obfs,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &hy2TLS,
|
|
},
|
|
}
|
|
inboundOptions = opts
|
|
s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth)
|
|
case "anytls":
|
|
// V2bX: AnyTLS always uses TLS
|
|
anyTLS := tlsOptions
|
|
anyTLS.Enabled = true
|
|
|
|
opts := &option.AnyTLSInboundOptions{
|
|
ListenOptions: listen,
|
|
PaddingScheme: config.PaddingScheme,
|
|
InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
|
|
TLS: &anyTLS,
|
|
},
|
|
}
|
|
inboundOptions = opts
|
|
s.logger.Info("Xboard AnyTLS configured")
|
|
default:
|
|
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)
|
|
s.logger.Info("Xboard creating inbound [", inboundTag, "] on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")")
|
|
|
|
// Create new inbound
|
|
err = s.inboundManager.Create(s.ctx, s.router, 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 ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")")
|
|
|
|
// Register the new inbound in our managed list
|
|
inbound, _ := s.inboundManager.Get(inboundTag)
|
|
managedServer, isManaged := inbound.(adapter.ManagedSSMServer)
|
|
if isManaged {
|
|
tracker := newXboardTracker(s)
|
|
traffic := tracker.TrafficManager()
|
|
managedServer.SetTracker(tracker)
|
|
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 managed inbound [", inboundTag, "] registered (protocol: ", protocol, ")")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizePanelNodeType(nodeType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(nodeType)) {
|
|
case "v2ray":
|
|
return "vmess"
|
|
case "hysteria2":
|
|
return "hysteria"
|
|
default:
|
|
return strings.ToLower(strings.TrimSpace(nodeType))
|
|
}
|
|
}
|
|
|
|
func (s *Service) panelRequest(method string, baseURL string, endpoint string, nodeID int, payload []byte, contentType string) (http.Header, []byte, int, error) {
|
|
nodeType := normalizePanelNodeType(s.options.NodeType)
|
|
nodeTypeCandidates := []string{""}
|
|
if nodeType != "" {
|
|
nodeTypeCandidates = append(nodeTypeCandidates, nodeType)
|
|
}
|
|
|
|
var lastHeader http.Header
|
|
var lastBody []byte
|
|
var lastStatus int
|
|
for index, candidate := range nodeTypeCandidates {
|
|
requestURL, err := url.Parse(strings.TrimRight(baseURL, "/") + endpoint)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
query := requestURL.Query()
|
|
query.Set("node_id", strconv.Itoa(nodeID))
|
|
query.Set("token", s.options.Key)
|
|
if candidate != "" {
|
|
query.Set("node_type", candidate)
|
|
}
|
|
requestURL.RawQuery = query.Encode()
|
|
|
|
var bodyReader io.Reader
|
|
if payload != nil {
|
|
bodyReader = bytes.NewReader(payload)
|
|
}
|
|
req, _ := http.NewRequest(method, requestURL.String(), bodyReader)
|
|
req.Header.Set("User-Agent", "sing-box/xboard")
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
logNodeType := candidate
|
|
if logNodeType == "" {
|
|
logNodeType = "<empty>"
|
|
}
|
|
s.logger.Info("Xboard panel request. endpoint=", endpoint, ", node_id=", nodeID, ", node_type=", logNodeType)
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
responseBody, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, nil, 0, readErr
|
|
}
|
|
|
|
lastHeader = resp.Header.Clone()
|
|
lastBody = responseBody
|
|
lastStatus = resp.StatusCode
|
|
|
|
if resp.StatusCode == 400 && candidate == "" && strings.Contains(string(responseBody), "Server does not exist") && index+1 < len(nodeTypeCandidates) {
|
|
s.logger.Warn("Xboard panel request failed without node_type, retrying with configured node_type=", nodeType)
|
|
continue
|
|
}
|
|
|
|
return lastHeader, lastBody, lastStatus, nil
|
|
}
|
|
|
|
return lastHeader, lastBody, lastStatus, 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
|
|
}
|
|
headers, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/config", nodeID, nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check time drift
|
|
if dateStr := headers.Get("Date"); dateStr != "" {
|
|
if panelTime, err := http.ParseTime(dateStr); err == nil {
|
|
localTime := time.Now()
|
|
drift := localTime.Sub(panelTime)
|
|
if drift < 0 { drift = -drift }
|
|
s.logger.Info("TIME CHECK: Panel Local [", panelTime.Format(time.RFC3339), "] vs Server Local [", localTime.Format(time.RFC3339), "]. Drift: ", drift)
|
|
if drift > 30*time.Second {
|
|
s.logger.Error("CRITICAL TIME DRIFT: Your server time is OUT OF SYNC. Shadowsocks 2022 WILL FAIL!")
|
|
}
|
|
}
|
|
}
|
|
|
|
if statusCode != 200 {
|
|
return nil, E.New("failed to fetch config, status: ", statusCode, ", body: ", string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Data XNodeConfig `json:"data"`
|
|
}
|
|
err = json.Unmarshal(body, &result)
|
|
if err != nil || (result.Data.Protocol == "" && result.Data.NodeType == "" && result.Data.ServerPort == 0) {
|
|
// Try unmarshaling WITHOUT "data" wrapper
|
|
var flatResult XNodeConfig
|
|
if err2 := json.Unmarshal(body, &flatResult); err2 == nil {
|
|
s.logger.Info(
|
|
"Xboard config fetched (flat). protocol=", flatResult.Protocol,
|
|
", node_type=", flatResult.NodeType,
|
|
", port=", flatResult.Port,
|
|
", server_port=", flatResult.ServerPort,
|
|
)
|
|
return &flatResult, nil
|
|
}
|
|
|
|
s.logger.Error("Xboard decoder error: ", err)
|
|
s.logger.Error("Xboard raw config response: ", string(body))
|
|
return nil, err
|
|
}
|
|
|
|
// Final safety check
|
|
if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" && result.Data.ServerPort == 0 {
|
|
s.logger.Error("Xboard config mapping failed (fields missing). Data: ", string(body))
|
|
}
|
|
s.logger.Info(
|
|
"Xboard config fetched. protocol=", result.Data.Protocol,
|
|
", node_type=", result.Data.NodeType,
|
|
", port=", result.Data.Port,
|
|
", server_port=", result.Data.ServerPort,
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
if len(users) == 0 {
|
|
s.logger.Warn("Xboard sync: no users returned from panel. Check your Node ID and User config.")
|
|
}
|
|
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
|
|
newUsers := make(map[string]userData)
|
|
isSS2022 := strings.Contains(s.ssCipher, "2022")
|
|
ss2022KeyLen := 0
|
|
if isSS2022 {
|
|
ss2022KeyLen = ss2022KeyLength(s.ssCipher)
|
|
}
|
|
|
|
for _, u := range users {
|
|
userName := u.Identifier()
|
|
if userName == "" {
|
|
continue
|
|
}
|
|
key := s.resolveUserKey(u, isSS2022)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
|
|
// V2bX/Xboard approach for SS2022 user key:
|
|
if isSS2022 {
|
|
originalKey := key
|
|
key = ss2022Key(key, ss2022KeyLen)
|
|
s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key)
|
|
}
|
|
|
|
flow := u.Flow
|
|
if s.protocol == "vless" && flow == "" {
|
|
flow = s.vlessFlow
|
|
}
|
|
if s.protocol == "vless" && flow == "xtls-rprx-vision" && s.vlessServerName == "" {
|
|
s.logger.Warn("Xboard VLESS flow xtls-rprx-vision kept but panel did not provide server_name")
|
|
}
|
|
|
|
newUsers[userName] = userData{
|
|
ID: u.ID,
|
|
Email: userName,
|
|
Key: key,
|
|
Flow: flow,
|
|
}
|
|
}
|
|
|
|
if sameUserSet(s.localUsers, newUsers) {
|
|
s.logger.Trace("Xboard sync skipped: users unchanged")
|
|
return
|
|
}
|
|
|
|
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 sameUserSet(current map[string]userData, next map[string]userData) bool {
|
|
if len(current) != len(next) {
|
|
return false
|
|
}
|
|
for userName, currentUser := range current {
|
|
nextUser, exists := next[userName]
|
|
if !exists {
|
|
return false
|
|
}
|
|
if currentUser != nextUser {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Service) reportTraffic() {
|
|
s.logger.Trace("Xboard reporting traffic...")
|
|
|
|
s.access.Lock()
|
|
localUsers := s.localUsers
|
|
s.access.Unlock()
|
|
|
|
if len(localUsers) == 0 {
|
|
return
|
|
}
|
|
|
|
usageMap := make(map[string][2]int64)
|
|
|
|
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
|
|
}
|
|
userID := strconv.Itoa(meta.ID)
|
|
item := usageMap[userID]
|
|
item[0] += u.UplinkBytes
|
|
item[1] += u.DownlinkBytes
|
|
usageMap[userID] = item
|
|
}
|
|
}
|
|
|
|
if len(usageMap) == 0 {
|
|
return
|
|
}
|
|
|
|
err := s.pushTraffic(usageMap)
|
|
if err != nil {
|
|
s.logger.Error("Xboard report error: ", err)
|
|
} else {
|
|
s.logger.Info("Xboard report completed, users reported: ", len(usageMap))
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
body, _ := json.Marshal(data)
|
|
|
|
_, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/push", nodeID, body, "application/json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if statusCode != 200 {
|
|
return E.New("failed to push traffic, status: ", statusCode, ", body: ", string(responseBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) sendAlive() {
|
|
nodeID := s.options.UserNodeID
|
|
if nodeID == 0 {
|
|
nodeID = s.options.ConfigNodeID
|
|
}
|
|
if nodeID == 0 {
|
|
nodeID = s.options.NodeID
|
|
}
|
|
baseURL := s.options.UserPanelURL
|
|
if baseURL == "" {
|
|
baseURL = s.options.ConfigPanelURL
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = s.options.PanelURL
|
|
}
|
|
|
|
payload := s.buildAlivePayload()
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
s.logger.Error("Xboard alive payload error: ", err)
|
|
return
|
|
}
|
|
|
|
_, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/alive", nodeID, body, "application/json")
|
|
if err != nil {
|
|
s.logger.Error("Xboard heartbeat error: ", err)
|
|
return
|
|
}
|
|
|
|
if statusCode != 200 {
|
|
s.logger.Warn("Xboard heartbeat failed, status: ", statusCode, ", body: ", string(responseBody))
|
|
} else {
|
|
s.logger.Trace("Xboard heartbeat sent, users: ", len(payload))
|
|
}
|
|
}
|
|
|
|
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) Identifier() string {
|
|
if u.UUID != "" {
|
|
return u.UUID
|
|
}
|
|
if u.Email != "" {
|
|
return u.Email
|
|
}
|
|
if u.ID != 0 {
|
|
return strconv.Itoa(u.ID)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (u *XUser) ResolveKey() string {
|
|
if u.Passwd != "" {
|
|
return u.Passwd
|
|
}
|
|
if u.Password != "" {
|
|
return u.Password
|
|
}
|
|
if u.UUID != "" {
|
|
return u.UUID
|
|
}
|
|
return u.Token
|
|
}
|
|
|
|
func (s *Service) resolveUserKey(u XUser, isSS2022 bool) string {
|
|
key := u.ResolveKey()
|
|
if !isSS2022 || key == "" {
|
|
return key
|
|
}
|
|
if strings.Contains(key, ":") {
|
|
serverKey, userKey, ok := strings.Cut(key, ":")
|
|
if ok && userKey != "" {
|
|
if s.ssServerKey != "" && serverKey != "" && serverKey != s.ssServerKey {
|
|
s.logger.Warn("Xboard SS2022 user key server key mismatch for user [", u.ID, "]")
|
|
}
|
|
return userKey
|
|
}
|
|
}
|
|
return key
|
|
}
|
|
|
|
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
|
|
}
|
|
_, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/user", nodeID, nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if statusCode != 200 {
|
|
return nil, E.New("failed to fetch users, status: ", statusCode, ", body: ", string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Data []XUser `json:"data"`
|
|
Users []XUser `json:"users"`
|
|
}
|
|
err = json.Unmarshal(body, &result)
|
|
if err != nil {
|
|
s.logger.Error("Xboard raw user response: ", string(body))
|
|
return nil, err
|
|
}
|
|
|
|
userList := result.Data
|
|
if len(userList) == 0 {
|
|
userList = result.Users
|
|
}
|
|
|
|
return userList, nil
|
|
}
|