package xray import ( "bytes" "context" "crypto/sha256" "encoding/json" "fmt" "log/slog" "os" "strings" "sync" "sync/atomic" "time" "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/uuid" xrayCore "github.com/xtls/xray-core/core" "github.com/xtls/xray-core/features/inbound" "github.com/xtls/xray-core/features/stats" "github.com/xtls/xray-core/infra/conf/serial" xrayProxy "github.com/xtls/xray-core/proxy" "github.com/xtls/xray-core/proxy/shadowsocks" ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022" "github.com/xtls/xray-core/proxy/trojan" "github.com/xtls/xray-core/proxy/vless" "github.com/xtls/xray-core/proxy/vmess" "golang.org/x/time/rate" _ "github.com/xtls/xray-core/main/distro/all" "github.com/cedar2025/xboard-node/internal/config" "github.com/cedar2025/xboard-node/internal/kernel" "github.com/cedar2025/xboard-node/internal/kernel/geodata" "github.com/cedar2025/xboard-node/internal/panel" ) const ( // drainTimeout is how long Stop waits for in-flight connections to finish // before hard-killing the instance. Skipped during hot-reload for speed. drainTimeout = 5 * time.Second // startTimeout caps how long instance.Start() may block. startTimeout = 30 * time.Second ) // Xray implements kernel.Kernel by embedding xray-core as a Go library. // // Lifecycle: Start → running → Stop / Reload // // Lock discipline: // - mu is held only for brief field swaps (microseconds). // - instance.Start() and instance.Close() run OUTSIDE the lock. // - running (atomic) gates fast-path checks in IsRunning / GetConnections. type Xray struct { cfg config.KernelConfig // mu protects instance, limitDispatcher, users, protocol, inboundTag, // lastKernelHash, and cumTraffic. Never held during slow I/O. mu sync.Mutex instance *xrayCore.Instance limitDispatcher *LimitDispatcher users []panel.User nodeConfig *panel.NodeConfig certFile string keyFile string protocol string inboundTag string lastKernelHash string cumTraffic map[int][2]int64 // running is set after a successful Start and cleared before shutdown. // Atomic so IsRunning / GetConnections never block. running atomic.Bool } func New(cfg config.KernelConfig) *Xray { return &Xray{ cfg: cfg, cumTraffic: make(map[int][2]int64), } } func (x *Xray) Name() string { return "xray" } func (x *Xray) Protocols() []string { return []string{ "vmess", "vless", "trojan", "shadowsocks", "socks", "http", "dokodemo-door", } } // ─── Lifecycle ────────────────────────────────────────────────────────────── // Start builds a new xray-core instance and atomically replaces the old // one. The method is organised in five non-overlapping phases so that the // kernel mutex is never held during slow operations (Start / Close). // Crucially, the old instance stays alive until the new one is confirmed // running — if StartNew fails, the old instance is untouched. // // Phase 1 – Build: generate protobuf config (no lock, pure computation) // Phase 2 – Create: xrayCore.New + capture LD (brief global lock) // Phase 3 – StartNew: instance.Start (no lock, potentially slow) // Phase 4 – Swap: store new, extract old (brief kernel lock) // Phase 5 – RecycleOld: close old in background (non-blocking) func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { // ── Phase 1: Build config (no shared state) ───────────────────────── x.ensureGeoData(nodeConfig) data, err := marshalConfig(x.cfg, nodeConfig, users, certFile, keyFile) if err != nil { return err } pbConfig, err := serial.LoadJSONConfig(bytes.NewReader(data)) if err != nil { return fmt.Errorf("parse xray config: %w", err) } // ── Phase 2: Create instance (global lock for LD capture) ─────────── xrayCreationMu.Lock() inst, err := xrayCore.New(pbConfig) ld := globalLimitDispatcher.Load() xrayCreationMu.Unlock() if err != nil { return fmt.Errorf("create xray: %w", err) } // ── Phase 3: Start new (no lock, potentially slow) ────────────────── if err := startWithTimeout(inst, startTimeout); err != nil { inst.Close() return err } // ── Phase 4: Swap old → new (brief kernel lock) ───────────────────── x.mu.Lock() old := x.instance oldLD := x.limitDispatcher x.instance = inst x.limitDispatcher = ld x.users = users x.nodeConfig = nodeConfig x.certFile = certFile x.keyFile = keyFile x.protocol = nodeConfig.Protocol x.inboundTag = nodeConfig.Protocol + "-in" x.cumTraffic = make(map[int][2]int64) x.lastKernelHash = kernel.ComputeHash(nodeConfig, users) x.running.Store(true) x.mu.Unlock() // ── Phase 5: Recycle old (background, non-blocking) ───────────────── closeOld(old, oldLD) x.updateDispatcherLimits(users) slog.Info("xray started", "users", len(users), "protocol", nodeConfig.Protocol, ) return nil } // Reload updates dispatcher limits and handles configuration changes. // Since Xray does not support granular inbound reconstruction without an // instance restart for most transport/TLS settings, it triggers a full restart // if any kernel-affecting fields (hash mismatch) have changed. func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error { x.updateDispatcherLimits(users) newHash := kernel.ComputeHash(nodeConfig, users) x.mu.Lock() same := x.lastKernelHash == newHash if same { x.users = users x.mu.Unlock() slog.Debug("xray: limits updated, kernel configuration unchanged") return nil } x.mu.Unlock() slog.Info("xray: configuration changed, performing full restart") return x.Start(nodeConfig, users, certFile, keyFile) } // Stop gracefully shuts down the kernel, draining active connections first. func (x *Xray) Stop() { x.running.Store(false) x.mu.Lock() inst := x.instance ld := x.limitDispatcher x.instance = nil x.limitDispatcher = nil x.mu.Unlock() if ld != nil { drainConns(ld, drainTimeout) } closeOld(inst, ld) } func (x *Xray) IsRunning() bool { return x.running.Load() } // ─── Observability ────────────────────────────────────────────────────────── // GetUserTraffic returns per-user cumulative traffic and alive IPs. // Reads from LimitDispatcher's per-user atomic counters — O(users). func (x *Xray) GetUserTraffic(_ context.Context) (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int, err error) { if !x.running.Load() { return nil, nil, 0, nil } x.mu.Lock() ld := x.limitDispatcher x.mu.Unlock() if ld != nil { traffic, aliveIPs, connCount = ld.GetUserTraffic() return traffic, aliveIPs, connCount, nil } // Fallback: read xray's native stats counters traffic, err = x.aggregateStats() return traffic, nil, 0, err } func (x *Xray) CloseConnection(_ context.Context, _ string) error { // No-op: xray doesn't support force-closing individual connections. // User removal goes through RemoveUsers which removes the inbound user. return nil } func (x *Xray) CloseUserConnections(_ context.Context, _ string) error { // No-op: handled by RemoveUsers at the xray core level. return nil } func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {} // SetDeviceLimitFunc is a no-op for xray — device limits are already // gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time. func (x *Xray) SetDeviceLimitFunc(_ func(string) (int, bool)) {} // ─── User management (non-disruptive where possible) ──────────────────────── // AddUsers adds new users to the running kernel via xray's UserManager API. // For protocols that support UserManager (vmess, vless, trojan, shadowsocks), // this is truly hitless — no restart, no connection disruption. // For unsupported protocols (socks, http), falls back to full restart. // // Delta "add" events also carry property updates (speed/device limits) for // existing users, so this method always merges properties and refreshes the // dispatcher limits — even when no brand-new users need to be added. func (x *Xray) AddUsers(users []panel.User) (int, error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() return 0, fmt.Errorf("not running") } // Merge: overwrite existing users' properties, collect truly new ones. userMap := make(map[int]panel.User, len(x.users)) for _, u := range x.users { userMap[u.ID] = u } var toAdd []panel.User for _, u := range users { if _, exists := userMap[u.ID]; !exists { toAdd = append(toAdd, u) } userMap[u.ID] = u // always overwrite properties } merged := make([]panel.User, 0, len(userMap)) for _, u := range userMap { merged = append(merged, u) } if len(toAdd) == 0 { // No new kernel users, but properties (limits) may have changed. x.users = merged x.mu.Unlock() x.updateDispatcherLimits(merged) return 0, nil } um, err := x.getUserManager() if err != nil { // Protocol doesn't support UserManager → full restart nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile x.mu.Unlock() slog.Debug("xray: AddUsers fallback to restart", "reason", err) if err := x.Start(nc, merged, cf, kf); err != nil { return 0, err } return len(toAdd), nil } proto := x.protocol nc := x.nodeConfig x.mu.Unlock() ctx := context.Background() added := 0 for _, u := range toAdd { mu, err := toMemoryUser(proto, nc, u) if err != nil { slog.Warn("xray: skip user, cannot build account", "user", u.ID, "error", err) continue } if err := um.AddUser(ctx, mu); err != nil { slog.Warn("xray: AddUser failed", "user", u.ID, "error", err) continue } added++ } // Update bookkeeping with full merged list (new users + updated properties). x.mu.Lock() x.users = merged x.mu.Unlock() x.updateDispatcherLimits(merged) slog.Info("xray: users added via UserManager", "added", added, "total", len(merged)) return added, nil } // RemoveUsers removes users from the running kernel via xray's UserManager API. // Truly hitless for supported protocols — remaining connections unaffected. func (x *Xray) RemoveUsers(users []panel.User) (int, error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() return 0, fmt.Errorf("not running") } removeSet := make(map[int]struct{}, len(users)) for _, u := range users { removeSet[u.ID] = struct{}{} } var kept []panel.User removed := 0 for _, u := range x.users { if _, rm := removeSet[u.ID]; rm { removed++ } else { kept = append(kept, u) } } if removed == 0 { x.mu.Unlock() return 0, nil } if len(kept) == 0 { x.mu.Unlock() x.Stop() return removed, nil } um, err := x.getUserManager() if err != nil { nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile x.mu.Unlock() slog.Debug("xray: RemoveUsers fallback to restart", "reason", err) if err := x.Start(nc, kept, cf, kf); err != nil { return 0, err } return removed, nil } x.mu.Unlock() ctx := context.Background() actualRemoved := 0 for _, u := range users { email := userEmail(u.ID) if err := um.RemoveUser(ctx, email); err != nil { slog.Debug("xray: RemoveUser skipped", "user", u.ID, "error", err) continue } actualRemoved++ } x.mu.Lock() x.users = kept x.mu.Unlock() x.updateDispatcherLimits(kept) slog.Info("xray: users removed via UserManager", "removed", actualRemoved, "total", len(kept)) return actualRemoved, nil } // UpdateUsers replaces the entire user set. If only speed/device limits // changed, updates the dispatcher without restarting. Otherwise uses // UserManager for hitless add/remove where supported. func (x *Xray) UpdateUsers(users []panel.User) (added, removed int, err error) { x.mu.Lock() if x.instance == nil { x.mu.Unlock() return 0, 0, fmt.Errorf("not running") } toAdd, toRemove := kernel.UserDiff(x.users, users) added, removed = len(toAdd), len(toRemove) if added == 0 && removed == 0 { // Only limits changed — update dispatcher without restart. x.users = users x.mu.Unlock() x.updateDispatcherLimits(users) slog.Info("xray: limits updated, kernel unchanged") return 0, 0, nil } um, umErr := x.getUserManager() if umErr != nil { // Protocol doesn't support UserManager → full restart nc, cf, kf := x.nodeConfig, x.certFile, x.keyFile x.mu.Unlock() slog.Debug("xray: UpdateUsers fallback to restart", "reason", umErr) if err = x.Start(nc, users, cf, kf); err != nil { return 0, 0, err } return } proto := x.protocol nc := x.nodeConfig x.mu.Unlock() ctx := context.Background() // Remove first, then add (order matters for UUID changes on same ID) for _, u := range toRemove { email := userEmail(u.ID) if err := um.RemoveUser(ctx, email); err != nil { slog.Debug("xray: RemoveUser skipped in UpdateUsers", "user", u.ID, "error", err) } } for _, u := range toAdd { mu, err := toMemoryUser(proto, nc, u) if err != nil { slog.Warn("xray: skip user in UpdateUsers", "user", u.ID, "error", err) continue } if err := um.AddUser(ctx, mu); err != nil { slog.Warn("xray: AddUser failed in UpdateUsers", "user", u.ID, "error", err) } } x.mu.Lock() x.users = users x.mu.Unlock() x.updateDispatcherLimits(users) slog.Info("xray: users updated via UserManager", "added", added, "removed", removed, "total", len(users)) return } var _ kernel.Kernel = (*Xray)(nil) // ─── Internal helpers ─────────────────────────────────────────────────────── // getUserManager extracts the UserManager from the running xray instance's // inbound handler. Must be called with x.mu held. // Returns error for protocols that don't support UserManager (socks, http). func (x *Xray) getUserManager() (xrayProxy.UserManager, error) { if x.instance == nil { return nil, fmt.Errorf("instance is nil") } im := x.instance.GetFeature(inbound.ManagerType()) if im == nil { return nil, fmt.Errorf("inbound manager not available") } inboundMgr, ok := im.(inbound.Manager) if !ok { return nil, fmt.Errorf("inbound manager type assertion failed") } handler, err := inboundMgr.GetHandler(context.Background(), x.inboundTag) if err != nil { return nil, fmt.Errorf("get handler %q: %w", x.inboundTag, err) } gi, ok := handler.(xrayProxy.GetInbound) if !ok { return nil, fmt.Errorf("handler does not implement GetInbound") } um, ok := gi.GetInbound().(xrayProxy.UserManager) if !ok { return nil, fmt.Errorf("protocol %q does not support UserManager", x.protocol) } return um, nil } // toMemoryUser converts a panel.User into an xray protocol.MemoryUser for the // given protocol. Each protocol needs a different Account type. func toMemoryUser(proto string, nc *panel.NodeConfig, u panel.User) (*protocol.MemoryUser, error) { email := userEmail(u.ID) mu := &protocol.MemoryUser{Email: email, Level: 0} switch proto { case "vmess": id, err := uuid.ParseString(u.UUID) if err != nil { return nil, fmt.Errorf("parse vmess UUID: %w", err) } mu.Account = &vmess.MemoryAccount{ ID: protocol.NewID(id), Security: protocol.SecurityType_AUTO, } case "vless": id, err := uuid.ParseString(u.UUID) if err != nil { return nil, fmt.Errorf("parse vless UUID: %w", err) } account := &vless.MemoryAccount{ ID: protocol.NewID(id), Encryption: "none", } if nc.Flow != "" { account.Flow = nc.Flow } mu.Account = account case "trojan": mu.Account = &trojan.MemoryAccount{ Password: u.UUID, Key: trojanHexSha224(u.UUID), } case "shadowsocks": if strings.HasPrefix(nc.Cipher, "2022-blake3-") { // 2022-blake3 multi-user mode mu.Account = &ss2022.MemoryAccount{Key: u.UUID} } else { // Traditional SS — build via getCipher path ct := parseCipherType(nc.Cipher) cipherObj, err := (&shadowsocks.Account{CipherType: ct, Password: u.UUID}).AsAccount() if err != nil { return nil, fmt.Errorf("build ss account: %w", err) } mu.Account = cipherObj } default: return nil, fmt.Errorf("protocol %q does not support MemoryUser", proto) } return mu, nil } // parseCipherType maps a cipher name string to the xray CipherType enum. func parseCipherType(cipher string) shadowsocks.CipherType { switch cipher { case "aes-128-gcm": return shadowsocks.CipherType_AES_128_GCM case "aes-256-gcm": return shadowsocks.CipherType_AES_256_GCM case "chacha20-ietf-poly1305", "chacha20-poly1305": return shadowsocks.CipherType_CHACHA20_POLY1305 case "xchacha20-ietf-poly1305", "xchacha20-poly1305": return shadowsocks.CipherType_XCHACHA20_POLY1305 case "none", "plain": return shadowsocks.CipherType_NONE default: return shadowsocks.CipherType_AES_256_GCM } } // trojanHexSha224 computes the SHA-224 hex key used by trojan protocol. func trojanHexSha224(password string) []byte { // Replicates the exact logic from xray-core/proxy/trojan/config.go hexSha224 h := sha256.New224() h.Write([]byte(password)) buf := make([]byte, 56) hexEncode(buf, h.Sum(nil)) return buf } // hexEncode is a minimal hex encoder (avoids importing encoding/hex). func hexEncode(dst, src []byte) { const hextable = "0123456789abcdef" for i, v := range src { dst[i*2] = hextable[v>>4] dst[i*2+1] = hextable[v&0x0f] } } // ensureGeoData downloads geo databases when routes reference geoip/geosite. func (x *Xray) ensureGeoData(nc *panel.NodeConfig) { needIP, needSite := kernel.NeedsGeoIP(nc.Routes), kernel.NeedsGeoSite(nc.Routes) if !needIP && !needSite { return } dir := x.cfg.GeoDataDir if err := geodata.Ensure(dir, needIP, needSite, "xray"); err != nil { slog.Warn("geo database unavailable", "error", err) } os.Setenv("XRAY_LOCATION_ASSET", dir) } // marshalConfig builds the xray JSON config and returns the raw bytes. func marshalConfig(cfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) ([]byte, error) { cfgMap := buildConfig(cfg, nc, users, certFile, keyFile) data, err := json.Marshal(cfgMap) if err != nil { return nil, fmt.Errorf("marshal config: %w", err) } slog.Debug("xray config generated", "len", len(data)) return data, nil } // startWithTimeout runs instance.Start() with a bounded deadline. func startWithTimeout(inst *xrayCore.Instance, timeout time.Duration) error { done := make(chan error, 1) go func() { done <- inst.Start() }() select { case err := <-done: if err != nil { return fmt.Errorf("start xray: %w", err) } return nil case <-time.After(timeout): return fmt.Errorf("start xray: timeout after %v", timeout) } } // closeOld shuts down a previously running instance and its dispatcher. // Recycling happens in a background goroutine to prevent the main thread // from blocking on slow connection draining, enabling "hitless" reload. func closeOld(inst *xrayCore.Instance, ld *LimitDispatcher) { if inst == nil { return } go func() { // 1. Drain connections gracefully (best effort, e.g. 5 minutes) // We use a much longer timeout here than the default Stop() because // it's running in background and doesn't block new user connections. if ld != nil { drainConns(ld, 5*time.Minute) } // 2. Hard close inst.Close() if ld != nil { ld.ResetConns() } slog.Debug("xray: old instance recycled") }() } // drainConns waits up to timeout for the dispatcher's active connections to // reach zero. Used only during graceful Stop, not during hot-reload. func drainConns(ld *LimitDispatcher, timeout time.Duration) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if ld.connCount.Load() == 0 { return } time.Sleep(100 * time.Millisecond) } } // aggregateStats is a fallback path that reads xray's built-in stats counters // when LimitDispatcher is not available. Returns per-user cumulative traffic. // Must be called with x.mu held. func (x *Xray) aggregateStats() (map[int][2]int64, error) { sm := x.instance.GetFeature(stats.ManagerType()) if sm == nil { return nil, nil } mgr, ok := sm.(stats.Manager) if !ok { return nil, nil } traffic := make(map[int][2]int64) for _, u := range x.users { email := userEmail(u.ID) var dUp, dDown int64 if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil { dUp = c.Set(0) } if c := mgr.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil { dDown = c.Set(0) } if dUp > 0 || dDown > 0 { cum := x.cumTraffic[u.ID] cum[0] += dUp cum[1] += dDown x.cumTraffic[u.ID] = cum } if cum := x.cumTraffic[u.ID]; cum[0] > 0 || cum[1] > 0 { traffic[u.ID] = cum } } return traffic, nil } // updateDispatcherLimits configures the LimitDispatcher with per-user // device and speed limits. func (x *Xray) updateDispatcherLimits(users []panel.User) { x.mu.Lock() ld := x.limitDispatcher x.mu.Unlock() if ld == nil { return } emailToUID := make(map[string]int, len(users)*2) deviceLimits := make(map[string]int) speedLimits := make(map[string]int) for _, u := range users { email := userEmail(u.ID) emailToUID[email] = u.ID emailToUID[u.UUID] = u.ID if u.DeviceLimit > 0 { deviceLimits[email] = u.DeviceLimit deviceLimits[u.UUID] = u.DeviceLimit } if u.SpeedLimit > 0 { speedLimits[email] = u.SpeedLimit speedLimits[u.UUID] = u.SpeedLimit } } ld.UpdateLimits(emailToUID, deviceLimits, speedLimits) } // xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture // so that concurrent Xray instances in multi-node mode each capture their // own LimitDispatcher. var xrayCreationMu sync.Mutex