package main import ( "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "regexp" "strconv" "strings" "sync" "syscall" "time" ) // XrayConfig holds Xray process management settings embedded in the main Config. type XrayConfig struct { Enabled bool `json:"enabled"` BinPath string `json:"bin_path"` // external mode only, e.g. /opt/sshpanel/xray ConfigFile string `json:"config_file"` // external runtime config, e.g. /opt/sshpanel/xray_config.json NativeConfigFile string `json:"native_config_file,omitempty"` // native emulator config, independent from external xray // Mode selects the runtime implementation. Supported values: // native = run the in-process emulator implemented in this binary // external = spawn the external /opt/sshpanel/xray binary // Empty/unknown is normalized to native so older configs do not silently // keep using the external binary after this build is installed. Mode string `json:"mode,omitempty"` // Native is kept for backward compatibility with older configs/UI payloads. // New writes should set both Mode and Native. Runtime decisions use UseNative(). Native bool `json:"native,omitempty"` // Optional Xray API endpoint used for online client counters. If empty, // the panel auto-detects a local inbound tagged "api" from the Xray config. APIServer string `json:"api_server,omitempty"` // e.g. 127.0.0.1:10085 // A client is considered online when its Xray stats traffic changed recently. OnlineWindowSeconds int `json:"online_window_seconds,omitempty"` // default 90 StatsPollSeconds int `json:"stats_poll_seconds,omitempty"` // default 15 // NativeIPStrategy controls native outbound address selection. The native // runtime defaults to auto dual-stack behavior so IPv6 destinations from // clients are preserved when the server has IPv6 connectivity. force_ipv4 is // only kept as an explicit legacy override. NativeIPStrategy string `json:"native_ip_strategy,omitempty"` // auto | force_ipv4 // NativeTuning exposes native-emulator scale/performance knobs in the admin panel. // These replace the old XRAY_NATIVE_* systemd environment overrides. NativeTuning *XrayNativeTuning `json:"native_tuning,omitempty"` } const ( xrayModeNative = "native" xrayModeExternal = "external" defaultXrayBinPath = "/opt/sshpanel/xray" defaultXrayConfigFile = "/opt/sshpanel/xray_config.json" defaultXrayNativeConfigFile = "/opt/sshpanel/xray_native_config.json" xrayNativeIPStrategyAuto = "auto" xrayNativeIPStrategyForce4 = "force_ipv4" ) func (c *XrayConfig) NormalizeDefaults() { if c == nil { return } mode := strings.ToLower(strings.TrimSpace(c.Mode)) switch mode { case xrayModeExternal: c.Mode = xrayModeExternal c.Native = false case xrayModeNative: c.Mode = xrayModeNative c.Native = true default: // Old configs did not have mode/native and used the external binary by // accident. For the emulator build, make internal/native the safe default. c.Mode = xrayModeNative c.Native = true } if strings.TrimSpace(c.ConfigFile) == "" { c.ConfigFile = defaultXrayConfigFile } if strings.TrimSpace(c.NativeConfigFile) == "" { c.NativeConfigFile = defaultXrayNativeConfigFile } if strings.TrimSpace(c.BinPath) == "" { c.BinPath = defaultXrayBinPath } switch strings.ToLower(strings.TrimSpace(c.NativeIPStrategy)) { case "ipv4", "useipv4", "use_ipv4", xrayNativeIPStrategyForce4: c.NativeIPStrategy = xrayNativeIPStrategyForce4 default: c.NativeIPStrategy = xrayNativeIPStrategyAuto } tuning := normalizeNativeXrayTuning(c.NativeTuning) c.NativeTuning = &tuning applyNativeXrayTuning(c.NativeTuning) } func (c *XrayConfig) NativeForceIPv4() bool { if c == nil { return false } mode := strings.ToLower(strings.TrimSpace(c.NativeIPStrategy)) return mode == xrayNativeIPStrategyForce4 || mode == "ipv4" || mode == "useipv4" || mode == "use_ipv4" } func (c *XrayConfig) ModeName() string { if c == nil { return "disabled" } mode := strings.ToLower(strings.TrimSpace(c.Mode)) if mode == xrayModeExternal { return xrayModeExternal } if mode == xrayModeNative || c.Native { return xrayModeNative } return xrayModeNative } func (c *XrayConfig) UseNative() bool { return c != nil && c.ModeName() != xrayModeExternal } func (c *XrayConfig) ActiveConfigFile() string { if c == nil { return "" } if c.UseNative() { if strings.TrimSpace(c.NativeConfigFile) != "" { return strings.TrimSpace(c.NativeConfigFile) } return defaultXrayNativeConfigFile } if strings.TrimSpace(c.ConfigFile) != "" { return strings.TrimSpace(c.ConfigFile) } return defaultXrayConfigFile } func (c *XrayConfig) ExternalConfigFile() string { if c == nil { return "" } if strings.TrimSpace(c.ConfigFile) != "" { return strings.TrimSpace(c.ConfigFile) } return defaultXrayConfigFile } // xrayLogRing is a fixed-capacity circular buffer for captured log lines. type xrayLogRing struct { mu sync.Mutex lines []string pos int } const ( xrayLogCap = 5000 xrayForcedLogLevel = "debug" xrayForcedAccessLogPath = "/dev/stdout" xrayForcedErrorLogPath = "/dev/stderr" ) func (r *xrayLogRing) add(line string) { r.mu.Lock() defer r.mu.Unlock() if len(r.lines) < xrayLogCap { r.lines = append(r.lines, line) } else { r.lines[r.pos] = line r.pos = (r.pos + 1) % xrayLogCap } } func (r *xrayLogRing) snapshot() []string { r.mu.Lock() defer r.mu.Unlock() if len(r.lines) == 0 { return nil } out := make([]string, len(r.lines)) if len(r.lines) < xrayLogCap { copy(out, r.lines) } else { n := copy(out, r.lines[r.pos:]) copy(out[n:], r.lines[:r.pos]) } return out } var xrayLogBuf = &xrayLogRing{} // xrayWriter captures writes from the xray subprocess into the log ring buffer // and forwards them to stderr so they appear in the process log. type xrayWriter struct{} func (w xrayWriter) Write(p []byte) (int, error) { text := strings.TrimRight(string(p), "\n") for _, line := range strings.Split(text, "\n") { if line != "" { xrayLogBuf.add(line) } } return os.Stderr.Write(p) } // xrayLogf writes native/runtime Xray debug messages directly to stderr and // to the in-memory Xray log ring. It intentionally bypasses the global // standard logger because the panel can run in quiet mode and call // log.SetOutput(io.Discard). Xray debug logs must remain visible while // troubleshooting transport issues. func xrayLogf(format string, args ...interface{}) { msg := strings.TrimRight(fmt.Sprintf(format, args...), "\n") if strings.TrimSpace(msg) == "" { return } ts := time.Now().Format("2006/01/02 15:04:05") for _, line := range strings.Split(msg, "\n") { if line == "" { continue } full := ts + " " + line xrayLogBuf.add(full) _, _ = fmt.Fprintln(os.Stderr, full) } } // xrayTracef is for very hot transport-level traces such as every XHTTP packet. // Leaving those on under many QUIC users can bottleneck on journald/stderr and // make the tunnel appear slow. Enable only when packet-level tracing is needed: // Enable packet-level tracing from Admin Panel -> Xray Native Scale -> Trace packets. func xrayTracef(format string, args ...interface{}) { if !nativeTracePacketsEnabled() { return } xrayLogf(format, args...) } // XrayManager manages the lifecycle of the external xray subprocess. type XrayManager struct { mu sync.Mutex cmd *exec.Cmd doneCh chan struct{} cfg *XrayConfig startTime time.Time lastErr string statsMu sync.RWMutex statsByEmail map[string]xrayRuntimeStat lastStatsErr string lastStatsPoll time.Time pollStarted bool nativeDBMu sync.Mutex nativeTrafficPending map[string]xrayPendingTraffic nativeStatsFlushStarted bool } type xrayTrafficCounters struct { Uplink int64 Downlink int64 } type xrayRuntimeStat struct { Email string Uplink int64 Downlink int64 LastActive time.Time ActiveConnections int } type xrayPendingTraffic struct { Email string Uplink int64 Downlink int64 } var xrayMgr = &XrayManager{} // initXrayManager stores the config and auto-starts Xray if Enabled is true. func initXrayManager(cfg *XrayConfig) { if cfg == nil { return } xrayMgr.mu.Lock() xrayMgr.cfg = cfg if err := xrayMgr.bootstrapConfigStoreLocked(); err != nil { xrayLogf("xray: database config bootstrap failed: %v", err) } xrayMgr.mu.Unlock() // In native mode the in-process emulator records traffic directly, so the // external `xray api statsquery` poller is not started (it would overwrite // the native counters with errors from a non-existent CLI endpoint). xrayMgr.startNativeStatsFlusher() if !cfg.UseNative() { xrayMgr.startStatsPoller() } if cfg.Enabled { if err := xrayMgr.Start(); err != nil { xrayLogf("xray: auto-start failed: %v", err) } } } // isRunning returns true if the subprocess is currently alive. // Must be called with m.mu held. func (m *XrayManager) isRunning() bool { if m.doneCh == nil { return false } select { case <-m.doneCh: return false default: return true } } // Start launches the xray subprocess. Returns an error if already running or misconfigured. func (m *XrayManager) Start() error { m.mu.Lock() defer m.mu.Unlock() if m.cfg == nil { return fmt.Errorf("xray not configured") } m.cfg.NormalizeDefaults() if err := m.syncConfigFileFromStoreLocked(); err != nil { m.lastErr = err.Error() return err } configFile := m.activeConfigFileLocked() if _, err := m.readConfigLocked(); err != nil && !os.IsNotExist(err) { m.lastErr = err.Error() return err } // Native mode: run the in-process emulator instead of the subprocess. if m.cfg.UseNative() { if nativeXray.nativeRunning() { return fmt.Errorf("native xray already running") } if err := nativeXray.start(configFile); err != nil { m.lastErr = err.Error() return err } m.startTime = time.Now() m.lastErr = "" return nil } if m.isRunning() { return fmt.Errorf("xray already running (pid %d)", m.cmd.Process.Pid) } if _, err := os.Stat(m.cfg.BinPath); err != nil { return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath) } if changed, err := m.ensureStatsAPIConfigLocked(); err != nil { return fmt.Errorf("xray stats api check failed: %w", err) } else if changed { xrayLogf("xray: repaired Stats API support and forced debug logs in config before start") } args := []string{"run"} if configFile != "" { args = append(args, "-c", configFile) } cmd := exec.Command(m.cfg.BinPath, args...) cmd.Stdout = xrayWriter{} cmd.Stderr = xrayWriter{} if err := cmd.Start(); err != nil { m.lastErr = err.Error() return fmt.Errorf("xray start: %w", err) } doneCh := make(chan struct{}) m.cmd = cmd m.doneCh = doneCh m.startTime = time.Now() m.lastErr = "" go func() { err := cmd.Wait() close(doneCh) m.mu.Lock() if err != nil { m.lastErr = err.Error() } m.mu.Unlock() xrayLogf("xray: process exited: %v", err) }() xrayLogf("xray: started (pid %d)", cmd.Process.Pid) return nil } // Stop sends SIGTERM and waits up to 5 s before forcing SIGKILL. func (m *XrayManager) Stop() error { // Stop both implementations defensively. Mode can change at runtime; relying // only on the new cfg.Native value can leak the old external subprocess when // switching external -> native, or leave native listeners bound when switching // native -> external. nativeXray.stop() m.flushNativeStatsToDB() m.mu.Lock() if !m.isRunning() { m.mu.Unlock() return nil } doneCh := m.doneCh cmd := m.cmd m.mu.Unlock() _ = cmd.Process.Signal(syscall.SIGTERM) select { case <-doneCh: case <-time.After(5 * time.Second): _ = cmd.Process.Kill() select { case <-doneCh: case <-time.After(2 * time.Second): } } xrayLogf("xray: stopped") return nil } // Restart stops then starts the xray subprocess. func (m *XrayManager) Restart() error { if err := m.Stop(); err != nil { return err } return m.Start() } // recordNativeConnect marks a native client stream as online immediately. This // is more accurate than external Xray's Stats API polling because it knows when // the decoded VMess/VLESS stream is authenticated and opened. func (m *XrayManager) recordNativeConnect(uuid, email string) { uuid = strings.TrimSpace(uuid) email = strings.TrimSpace(email) if email == "" { email = uuid } if uuid == "" && email == "" { return } now := time.Now() m.statsMu.Lock() if m.statsByEmail == nil { m.statsByEmail = make(map[string]xrayRuntimeStat) } st := m.statsByEmail[email] st.Email = email st.LastActive = now st.ActiveConnections++ m.statsByEmail[email] = st m.statsMu.Unlock() if statsStore != nil && uuid != "" { xrayGo("native xray stats active increment", func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, 1); err != nil { xrayLogf("xray native stats: active +1 for %s failed: %v", uuid, err) } }) } } func (m *XrayManager) recordNativeDisconnect(uuid, email string) { uuid = strings.TrimSpace(uuid) email = strings.TrimSpace(email) if email == "" { email = uuid } if uuid == "" && email == "" { return } m.statsMu.Lock() if m.statsByEmail != nil { st := m.statsByEmail[email] if st.ActiveConnections > 0 { st.ActiveConnections-- } m.statsByEmail[email] = st } m.statsMu.Unlock() if statsStore != nil && uuid != "" { xrayGo("native xray stats active decrement", func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, -1); err != nil { xrayLogf("xray native stats: active -1 for %s failed: %v", uuid, err) } }) } } // recordNativeTraffic accumulates in-process byte counters for a client and // queues DB persistence. Used by the native emulator instead of external // `xray api statsquery` polling. func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) { uuid = strings.TrimSpace(uuid) email = strings.TrimSpace(email) if email == "" { email = uuid } if email == "" || (up == 0 && down == 0) { return } now := time.Now() m.statsMu.Lock() if m.statsByEmail == nil { m.statsByEmail = make(map[string]xrayRuntimeStat) } st := m.statsByEmail[email] st.Email = email st.Uplink += up st.Downlink += down st.LastActive = now m.statsByEmail[email] = st m.statsMu.Unlock() if statsStore != nil && uuid != "" { m.nativeDBMu.Lock() if m.nativeTrafficPending == nil { m.nativeTrafficPending = make(map[string]xrayPendingTraffic) } p := m.nativeTrafficPending[uuid] p.Email = email p.Uplink += up p.Downlink += down m.nativeTrafficPending[uuid] = p m.nativeDBMu.Unlock() } } func (m *XrayManager) startNativeStatsFlusher() { m.nativeDBMu.Lock() if m.nativeStatsFlushStarted { m.nativeDBMu.Unlock() return } m.nativeStatsFlushStarted = true m.nativeDBMu.Unlock() xrayGo("native xray stats flusher", func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { m.flushNativeStatsToDB() m.pruneStaleRuntimeStats() } }) } // pruneStaleRuntimeStats drops runtime stat entries for clients that have no // active connections and have been idle well past the online window. Without // this, statsByEmail keeps one entry per email/UUID that has ever connected and // never shrinks. The retention comfortably exceeds the online-detection grace // window so CountOnlineUsers is unaffected; persistent traffic totals live in // the DB, so dropping the in-memory counter for a long-offline client is safe. func (m *XrayManager) pruneStaleRuntimeStats() { retention := 2 * m.onlineWindow() if retention < 5*time.Minute { retention = 5 * time.Minute } cutoff := time.Now().Add(-retention) m.statsMu.Lock() for email, st := range m.statsByEmail { if st.ActiveConnections <= 0 && (st.LastActive.IsZero() || st.LastActive.Before(cutoff)) { delete(m.statsByEmail, email) } } m.statsMu.Unlock() } func (m *XrayManager) flushNativeStatsToDB() { if statsStore == nil { return } m.nativeDBMu.Lock() pending := m.nativeTrafficPending m.nativeTrafficPending = nil m.nativeDBMu.Unlock() if len(pending) == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := statsStore.AddXrayClientTrafficBatch(ctx, pending); err != nil { xrayLogf("xray native stats: db traffic flush failed: %v", err) // Put deltas back so a transient DB failure does not lose accounting. m.nativeDBMu.Lock() if m.nativeTrafficPending == nil { m.nativeTrafficPending = make(map[string]xrayPendingTraffic) } for uuid, d := range pending { p := m.nativeTrafficPending[uuid] if p.Email == "" { p.Email = d.Email } p.Uplink += d.Uplink p.Downlink += d.Downlink m.nativeTrafficPending[uuid] = p } m.nativeDBMu.Unlock() } } // XrayStatusDTO is returned by /api/xray/status. type XrayStatusDTO struct { Enabled bool `json:"enabled"` Running bool `json:"running"` Mode string `json:"mode"` Native bool `json:"native"` PID int `json:"pid,omitempty"` Uptime string `json:"uptime,omitempty"` Error string `json:"error,omitempty"` OnlineUsers int `json:"online_users"` StatsError string `json:"stats_error,omitempty"` StatsConfigured bool `json:"stats_configured"` StatsMissing []string `json:"stats_missing,omitempty"` APIServer string `json:"api_server,omitempty"` LastStatsPoll *time.Time `json:"last_stats_poll,omitempty"` OnlineWindowSec int `json:"online_window_seconds"` } // Status returns a snapshot of the current xray process state. func (m *XrayManager) Status() XrayStatusDTO { m.mu.Lock() native := m.cfg != nil && m.cfg.UseNative() m.mu.Unlock() // Native mode: report the in-process emulator's state. Traffic counters are // recorded live so there is no CLI poll and no Stats API config to check. if native { m.mu.Lock() s := XrayStatusDTO{Enabled: m.cfg.Enabled, Mode: m.cfg.ModeName(), Native: m.cfg.UseNative()} if nativeXray.nativeRunning() { s.Running = true s.Uptime = time.Since(m.startTime).Round(time.Second).String() } if m.lastErr != "" { s.Error = m.lastErr } m.mu.Unlock() s.OnlineUsers = m.CountOnlineUsers() s.OnlineWindowSec = int(m.onlineWindow().Seconds()) s.StatsConfigured = true // in-process metering is always available return s } m.refreshRuntimeStatsIfStale(3 * time.Second) m.mu.Lock() s := XrayStatusDTO{} if m.cfg != nil { s.Enabled = m.cfg.Enabled s.Mode = m.cfg.ModeName() s.Native = m.cfg.UseNative() } if m.isRunning() && m.cmd != nil && m.cmd.Process != nil { s.Running = true s.PID = m.cmd.Process.Pid s.Uptime = time.Since(m.startTime).Round(time.Second).String() } if m.lastErr != "" { s.Error = m.lastErr } m.mu.Unlock() s.OnlineUsers = m.CountOnlineUsers() s.OnlineWindowSec = int(m.onlineWindow().Seconds()) m.statsMu.RLock() if m.lastStatsErr != "" { s.StatsError = m.lastStatsErr } if !m.lastStatsPoll.IsZero() { t := m.lastStatsPoll s.LastStatsPoll = &t } m.statsMu.RUnlock() if check, err := m.CheckStatsAPIConfig(); err == nil { s.StatsConfigured = check.Configured s.StatsMissing = check.Missing s.APIServer = check.APIServer } else if err != nil { s.StatsConfigured = false s.StatsMissing = []string{err.Error()} } return s } func (m *XrayManager) pollInterval() time.Duration { m.mu.Lock() defer m.mu.Unlock() sec := 15 if m.cfg != nil && m.cfg.StatsPollSeconds > 0 { sec = m.cfg.StatsPollSeconds } if sec < 5 { sec = 5 } return time.Duration(sec) * time.Second } func (m *XrayManager) onlineWindow() time.Duration { m.mu.Lock() defer m.mu.Unlock() sec := 90 if m.cfg != nil && m.cfg.OnlineWindowSeconds > 0 { sec = m.cfg.OnlineWindowSeconds } if sec < 15 { sec = 15 } return time.Duration(sec) * time.Second } func (m *XrayManager) startStatsPoller() { m.mu.Lock() if m.pollStarted { m.mu.Unlock() return } m.pollStarted = true m.mu.Unlock() go func() { // First sample establishes a baseline. Active Xray users appear online // after their traffic counters change on a later poll. for { m.refreshRuntimeStats() time.Sleep(m.pollInterval()) } }() } func (m *XrayManager) isRunningSnapshot() bool { m.mu.Lock() native := m.cfg != nil && m.cfg.UseNative() m.mu.Unlock() if native { return nativeXray.nativeRunning() } m.mu.Lock() defer m.mu.Unlock() return m.isRunning() } func (m *XrayManager) apiCommandConfig() (binPath, apiServer string, ok bool) { m.mu.Lock() defer m.mu.Unlock() if m.cfg == nil || m.cfg.BinPath == "" || m.cfg.ExternalConfigFile() == "" { return "", "", false } binPath = m.cfg.BinPath apiServer = strings.TrimSpace(m.cfg.APIServer) if apiServer == "" { apiServer = m.discoverAPIServerLocked() } return binPath, apiServer, apiServer != "" } func (m *XrayManager) discoverAPIServerLocked() string { if m.cfg == nil || m.cfg.ExternalConfigFile() == "" { return "" } data, err := os.ReadFile(m.cfg.ExternalConfigFile()) if err != nil { return "" } var cfg struct { Inbounds []struct { Tag string `json:"tag"` Listen string `json:"listen"` Protocol string `json:"protocol"` Port json.RawMessage `json:"port"` } `json:"inbounds"` } if err := json.Unmarshal(data, &cfg); err != nil { return "" } for _, ib := range cfg.Inbounds { if ib.Tag != "api" || !strings.EqualFold(ib.Protocol, "dokodemo-door") { continue } host := strings.TrimSpace(ib.Listen) if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } port := strings.Trim(string(ib.Port), `"`) if port == "" || port == "null" { continue } return host + ":" + port } return "" } func (m *XrayManager) refreshRuntimeStats() { if !m.isRunningSnapshot() { return } traffic, err := m.queryUserTraffic() now := time.Now() m.statsMu.Lock() defer m.statsMu.Unlock() m.lastStatsPoll = now if err != nil { m.lastStatsErr = err.Error() return } m.lastStatsErr = "" if m.statsByEmail == nil { m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic)) } seen := make(map[string]bool, len(traffic)) for email, counters := range traffic { seen[email] = true prev := m.statsByEmail[email] st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections} changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink // First successful poll with non-zero traffic means the client has been // active since Xray started. Later polls refresh LastActive only when bytes // move, which avoids counting old idle clients forever. if changed && (prev.Email != "" || counters.Uplink+counters.Downlink > 0) { st.LastActive = now } m.statsByEmail[email] = st } // Keep old entries, but do not delete them immediately. Xray may omit zero // counters for users that have not moved traffic yet. _ = seen } func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) { if !m.isRunningSnapshot() { return } m.statsMu.RLock() last := m.lastStatsPoll m.statsMu.RUnlock() if last.IsZero() || time.Since(last) >= maxAge { m.refreshRuntimeStats() } } func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) { binPath, apiServer, ok := m.apiCommandConfig() if !ok { return nil, fmt.Errorf("Xray API stats not configured; add an api inbound or set xray.api_server") } attempts := [][]string{ {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>", "-reset=false"}, {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>", "-reset=false"}, {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>"}, {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>"}, } var lastErr error for _, args := range attempts { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) cmd := exec.CommandContext(ctx, binPath, args...) out, err := cmd.CombinedOutput() cancel() if err == nil { return parseXrayStatsOutput(out), nil } lastErr = fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) } return nil, fmt.Errorf("Xray stats query failed: %v", lastErr) } func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters { result := map[string]xrayTrafficCounters{} type rawStat struct { Name string `json:"name"` Value json.RawMessage `json:"value"` } var js struct { Stat []rawStat `json:"stat"` Stats []rawStat `json:"stats"` } if json.Unmarshal(out, &js) == nil { for _, st := range js.Stat { addXrayCounter(result, st.Name, parseXrayStatValue(st.Value)) } for _, st := range js.Stats { addXrayCounter(result, st.Name, parseXrayStatValue(st.Value)) } } if len(result) > 0 { return result } // Text/protobuf form: name: "user>>>..." value: 123 reText := regexp.MustCompile(`(?s)name:\s*"([^"]+)"\s+value:\s*([0-9]+)`) for _, m := range reText.FindAllSubmatch(out, -1) { v, _ := strconv.ParseInt(string(m[2]), 10, 64) addXrayCounter(result, string(m[1]), v) } if len(result) > 0 { return result } // Some Xray/protobuf JSON builds encode int64 values as strings. reJSON := regexp.MustCompile(`"name"\s*:\s*"([^"]+)"[^}]*"value"\s*:\s*"?([0-9]+)"?`) for _, m := range reJSON.FindAllSubmatch(out, -1) { v, _ := strconv.ParseInt(string(m[2]), 10, 64) addXrayCounter(result, string(m[1]), v) } return result } func parseXrayStatValue(raw json.RawMessage) int64 { if len(raw) == 0 { return 0 } var n int64 if err := json.Unmarshal(raw, &n); err == nil { return n } var f float64 if err := json.Unmarshal(raw, &f); err == nil { return int64(f) } var s string if err := json.Unmarshal(raw, &s); err == nil { v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64) return v } return 0 } func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) { // Xray user traffic stats are normally named: // user>>>EMAIL>>>traffic>>>uplink // user>>>EMAIL>>>traffic>>>downlink // Older code expected a fifth segment and therefore ignored valid Xray // Stats API responses, which made every client look offline. parts := strings.Split(name, ">>>") if len(parts) < 4 || parts[0] != "user" || parts[2] != "traffic" { return } email := strings.TrimSpace(parts[1]) direction := strings.ToLower(strings.TrimSpace(parts[3])) if email == "" { return } c := result[email] switch direction { case "uplink": c.Uplink = value case "downlink": c.Downlink = value default: return } result[email] = c } func (m *XrayManager) RuntimeStatsForEmail(email string) (xrayRuntimeStat, bool) { return m.RuntimeStatsForKeys(email) } func (m *XrayManager) RuntimeStatsForKeys(keys ...string) (xrayRuntimeStat, bool) { m.statsMu.RLock() defer m.statsMu.RUnlock() for _, key := range keys { key = strings.TrimSpace(key) if key == "" { continue } if st, ok := m.statsByEmail[key]; ok && st.Email != "" { return st, true } } return xrayRuntimeStat{}, false } func (m *XrayManager) CountOnlineUsers() int { window := m.onlineWindow() now := time.Now() m.statsMu.RLock() defer m.statsMu.RUnlock() n := 0 for _, st := range m.statsByEmail { if st.ActiveConnections > 0 || (!st.LastActive.IsZero() && now.Sub(st.LastActive) <= window) { n++ } } return n } func (m *XrayManager) activeConfigFileLocked() string { if m.cfg == nil { return "" } return m.cfg.ActiveConfigFile() } func (m *XrayManager) configStoreKeyLocked() string { if m.cfg == nil { return "default" } mode := m.cfg.ModeName() path := strings.TrimSpace(m.activeConfigFileLocked()) if path == "" { path = "default" } return mode + ":" + path } func normalizeJSONIndent(data []byte) ([]byte, error) { var raw interface{} if err := json.Unmarshal(data, &raw); err != nil { return nil, err } return json.MarshalIndent(raw, "", " ") } func forceXrayDebugLogBytes(data []byte) ([]byte, bool, error) { if !json.Valid(data) { return nil, false, fmt.Errorf("invalid JSON") } var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { return nil, false, fmt.Errorf("parse xray config: %w", err) } if raw == nil { return nil, false, fmt.Errorf("xray config must be a JSON object") } changed := ensureXrayForcedDebugLogConfig(raw) if !changed { return data, false, nil } out, err := json.MarshalIndent(raw, "", " ") if err != nil { return nil, false, err } return out, true, nil } func ensureXrayForcedDebugLogConfig(raw map[string]interface{}) bool { changed := false logObj := asObject(raw["log"]) if logObj == nil { logObj = map[string]interface{}{} raw["log"] = logObj changed = true } if v, _ := logObj["access"].(string); v != xrayForcedAccessLogPath { logObj["access"] = xrayForcedAccessLogPath changed = true } if v, _ := logObj["error"].(string); v != xrayForcedErrorLogPath { logObj["error"] = xrayForcedErrorLogPath changed = true } if v, _ := logObj["loglevel"].(string); !strings.EqualFold(v, xrayForcedLogLevel) { logObj["loglevel"] = xrayForcedLogLevel changed = true } if v, _ := logObj["dnsLog"].(bool); !v { logObj["dnsLog"] = true changed = true } return changed } func (m *XrayManager) readConfigLocked() ([]byte, error) { configFile := m.activeConfigFileLocked() if m.cfg == nil || configFile == "" { return nil, fmt.Errorf("xray config file not configured") } if statsStore != nil { if data, ok, err := statsStore.GetXrayConfig(context.Background(), m.configStoreKeyLocked()); err != nil { xrayLogf("xray: database config read failed, falling back to file: %v", err) } else if ok { if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return nil, err } else if changed { data = forced _ = statsStore.UpsertXrayConfig(context.Background(), m.configStoreKeyLocked(), forced) xrayLogf("xray: forced debug log output in database config") } pretty, err := normalizeJSONIndent(data) if err != nil { return nil, err } _ = os.WriteFile(configFile, pretty, 0o600) return pretty, nil } } data, err := os.ReadFile(configFile) if err != nil { return nil, err } if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return nil, err } else if changed { data = forced _ = os.WriteFile(configFile, forced, 0o600) xrayLogf("xray: forced debug log output in file config") } return data, nil } func (m *XrayManager) writeConfigLocked(data []byte) error { configFile := m.activeConfigFileLocked() if m.cfg == nil || configFile == "" { return fmt.Errorf("xray config file not configured") } if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return err } else if changed { data = forced xrayLogf("xray: forced debug log output while saving config") } pretty, err := normalizeJSONIndent(data) if err != nil { return err } if statsStore != nil { if err := statsStore.UpsertXrayConfig(context.Background(), m.configStoreKeyLocked(), pretty); err != nil { return fmt.Errorf("save Xray config to database: %w", err) } } if err := os.WriteFile(configFile, pretty, 0o600); err != nil { return err } m.importConfigClientsLocked(pretty, "saved xray config") return nil } func (m *XrayManager) importConfigClientsLocked(data []byte, source string) { if statsStore == nil || len(data) == 0 { return } n, err := statsStore.ImportXrayClientsFromConfig(context.Background(), data) if err != nil { xrayLogf("xray: import clients from %s failed: %v", source, err) return } if n > 0 { xrayLogf("xray: imported/synced %d client UUIDs from %s into database", n, source) } } func (m *XrayManager) importRuntimeConfigFileClientsLocked(source string) { configFile := m.activeConfigFileLocked() if m.cfg == nil || strings.TrimSpace(configFile) == "" || statsStore == nil { return } data, err := os.ReadFile(configFile) if err != nil { if !os.IsNotExist(err) { xrayLogf("xray: read %s for client import failed: %v", configFile, err) } return } pretty, err := normalizeJSONIndent(data) if err != nil { xrayLogf("xray: cannot import clients from %s: invalid JSON: %v", configFile, err) return } m.importConfigClientsLocked(pretty, source) } func (m *XrayManager) restartIfExternalRunning() { m.mu.Lock() external := m.cfg != nil && !m.cfg.UseNative() running := external && m.isRunning() m.mu.Unlock() if !running { return } if err := m.Restart(); err != nil { xrayLogf("xray: external restart after client/config change failed: %v", err) } } func (m *XrayManager) useNativeMode() bool { m.mu.Lock() defer m.mu.Unlock() return m.cfg != nil && m.cfg.UseNative() } func (m *XrayManager) bootstrapConfigStoreLocked() error { configFile := m.activeConfigFileLocked() if m.cfg == nil || configFile == "" || statsStore == nil { return nil } ctx := context.Background() key := m.configStoreKeyLocked() if data, ok, err := statsStore.GetXrayConfig(ctx, key); err != nil { return err } else if ok { if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return err } else if changed { data = forced if err := statsStore.UpsertXrayConfig(ctx, key, forced); err != nil { return err } xrayLogf("xray: forced debug log output during config bootstrap") } pretty, err := normalizeJSONIndent(data) if err != nil { return err } m.importConfigClientsLocked(pretty, "database xray config") return os.WriteFile(configFile, pretty, 0o600) } data, err := os.ReadFile(configFile) if err != nil { if os.IsNotExist(err) && m.cfg.UseNative() { // One-time migration only: if this node already has an old external // config and no native config exists yet, clone it into the native path. // After this point native mode reads/writes only its own DB row/file. if migrated, migErr := m.seedNativeConfigFromExternalLocked(configFile); migErr != nil { return migErr } else if len(migrated) > 0 { if forced, changed, err := forceXrayDebugLogBytes(migrated); err != nil { return err } else if changed { migrated = forced xrayLogf("xray: forced debug log output during native config migration") } pretty, err := normalizeJSONIndent(migrated) if err != nil { return err } m.importConfigClientsLocked(pretty, "one-time external-to-native migration") if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil { return err } return os.WriteFile(configFile, pretty, 0o600) } } if os.IsNotExist(err) { return nil } return err } if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return err } else if changed { data = forced xrayLogf("xray: forced debug log output while importing runtime config") } pretty, err := normalizeJSONIndent(data) if err != nil { return err } m.importConfigClientsLocked(pretty, "runtime xray config") return statsStore.UpsertXrayConfig(ctx, key, pretty) } func (m *XrayManager) seedNativeConfigFromExternalLocked(nativeConfigFile string) ([]byte, error) { if m.cfg == nil || !m.cfg.UseNative() { return nil, nil } ext := strings.TrimSpace(m.cfg.ExternalConfigFile()) if ext == "" || ext == nativeConfigFile { return nil, nil } data, err := os.ReadFile(ext) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } if _, err := normalizeJSONIndent(data); err != nil { return nil, err } xrayLogf("xray native: cloned %s to independent native config %s once", ext, nativeConfigFile) return data, nil } func (m *XrayManager) syncConfigFileFromStoreLocked() error { configFile := m.activeConfigFileLocked() if m.cfg == nil || configFile == "" || statsStore == nil { return nil } data, ok, err := statsStore.GetXrayConfig(context.Background(), m.configStoreKeyLocked()) if err != nil || !ok { return err } if forced, changed, err := forceXrayDebugLogBytes(data); err != nil { return err } else if changed { data = forced if err := statsStore.UpsertXrayConfig(context.Background(), m.configStoreKeyLocked(), forced); err != nil { return err } xrayLogf("xray: forced debug log output while syncing config") } pretty, err := normalizeJSONIndent(data) if err != nil { return err } m.importConfigClientsLocked(pretty, "database xray config") return os.WriteFile(configFile, pretty, 0o600) } // GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the // database row is canonical and the file is only a synchronized runtime copy. func (m *XrayManager) GetConfig() ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() return m.readConfigLocked() } // SetConfig validates and writes a new Xray JSON config to DB and to the runtime file. func (m *XrayManager) SetConfig(data []byte) error { m.mu.Lock() defer m.mu.Unlock() patched, changed, err := patchXrayStatsAPIBytes(data) if err != nil { return err } if changed { xrayLogf("xray: added/repaired Stats API support and forced debug logs while saving config") } return m.writeConfigLocked(patched) } type xrayStatsConfigCheck struct { Configured bool Missing []string APIServer string } func (m *XrayManager) CheckStatsAPIConfig() (xrayStatsConfigCheck, error) { m.mu.Lock() defer m.mu.Unlock() return m.checkStatsAPIConfigLocked() } func (m *XrayManager) checkStatsAPIConfigLocked() (xrayStatsConfigCheck, error) { data, err := m.readConfigLocked() if err != nil { return xrayStatsConfigCheck{}, err } var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { return xrayStatsConfigCheck{}, fmt.Errorf("parse xray config: %w", err) } if raw == nil { return xrayStatsConfigCheck{}, fmt.Errorf("xray config must be a JSON object") } check := checkXrayStatsAPIConfig(raw) if check.APIServer == "" { check.APIServer = m.discoverAPIServerFromRaw(raw) } return check, nil } func (m *XrayManager) EnsureStatsAPIConfig() (bool, error) { m.mu.Lock() defer m.mu.Unlock() return m.ensureStatsAPIConfigLocked() } func (m *XrayManager) ensureStatsAPIConfigLocked() (bool, error) { data, err := m.readConfigLocked() if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } patched, changed, err := patchXrayStatsAPIBytes(data) if err != nil { return false, err } if !changed { return false, nil } return true, m.writeConfigLocked(patched) } func patchXrayStatsAPIBytes(data []byte) ([]byte, bool, error) { if !json.Valid(data) { return nil, false, fmt.Errorf("invalid JSON") } var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { return nil, false, fmt.Errorf("parse xray config: %w", err) } if raw == nil { return nil, false, fmt.Errorf("xray config must be a JSON object") } changed, _ := ensureXrayStatsAPIConfig(raw) if ensureXrayForcedDebugLogConfig(raw) { changed = true } if !changed { return data, false, nil } out, err := json.MarshalIndent(raw, "", " ") if err != nil { return nil, false, err } return out, true, nil } func ensureXrayStatsAPIConfig(raw map[string]interface{}) (bool, xrayStatsConfigCheck) { changed := false api := asObject(raw["api"]) if api == nil { api = map[string]interface{}{} raw["api"] = api changed = true } if tag, _ := api["tag"].(string); tag != "api" { api["tag"] = "api" changed = true } services, ok := api["services"].([]interface{}) if !ok { services = []interface{}{} } for _, svc := range []string{"HandlerService", "LoggerService", "StatsService"} { if !sliceHasString(services, svc) { services = append(services, svc) changed = true } } api["services"] = services if asObject(raw["stats"]) == nil { raw["stats"] = map[string]interface{}{} changed = true } policy := asObject(raw["policy"]) if policy == nil { policy = map[string]interface{}{} raw["policy"] = policy changed = true } levels := asObject(policy["levels"]) if levels == nil { levels = map[string]interface{}{} policy["levels"] = levels changed = true } level0 := asObject(levels["0"]) if level0 == nil { level0 = map[string]interface{}{} levels["0"] = level0 changed = true } for _, key := range []string{"statsUserUplink", "statsUserDownlink"} { if v, _ := level0[key].(bool); !v { level0[key] = true changed = true } } system := asObject(policy["system"]) if system == nil { system = map[string]interface{}{} policy["system"] = system changed = true } for _, key := range []string{"statsInboundUplink", "statsInboundDownlink"} { if v, _ := system[key].(bool); !v { system[key] = true changed = true } } inbounds, _ := raw["inbounds"].([]interface{}) apiInbound := findObjectByTag(inbounds, "api") if apiInbound == nil { apiInbound = map[string]interface{}{ "tag": "api", "listen": "127.0.0.1", "port": float64(10085), "protocol": "dokodemo-door", "settings": map[string]interface{}{"address": "127.0.0.1"}, } inbounds = append([]interface{}{apiInbound}, inbounds...) raw["inbounds"] = inbounds changed = true } else { if proto, _ := apiInbound["protocol"].(string); !strings.EqualFold(proto, "dokodemo-door") { apiInbound["protocol"] = "dokodemo-door" changed = true } if strings.TrimSpace(fmt.Sprint(apiInbound["listen"])) == "" { apiInbound["listen"] = "127.0.0.1" changed = true } if _, ok := apiInbound["port"]; !ok || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "" || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "" { apiInbound["port"] = float64(10085) changed = true } settings := asObject(apiInbound["settings"]) if settings == nil { settings = map[string]interface{}{} apiInbound["settings"] = settings changed = true } if strings.TrimSpace(fmt.Sprint(settings["address"])) == "" || strings.TrimSpace(fmt.Sprint(settings["address"])) == "" { settings["address"] = "127.0.0.1" changed = true } } outbounds, _ := raw["outbounds"].([]interface{}) if findObjectByTag(outbounds, "api") == nil { outbounds = append(outbounds, map[string]interface{}{"tag": "api", "protocol": "freedom", "settings": map[string]interface{}{}}) raw["outbounds"] = outbounds changed = true } routing := asObject(raw["routing"]) if routing == nil { routing = map[string]interface{}{} raw["routing"] = routing changed = true } rules, _ := routing["rules"].([]interface{}) if !hasAPIRoutingRule(rules) { rules = append([]interface{}{map[string]interface{}{"type": "field", "inboundTag": []interface{}{"api"}, "outboundTag": "api"}}, rules...) routing["rules"] = rules changed = true } if ensureXrayClientEmails(raw) { changed = true } return changed, checkXrayStatsAPIConfig(raw) } func checkXrayStatsAPIConfig(raw map[string]interface{}) xrayStatsConfigCheck { missing := []string{} api := asObject(raw["api"]) if api == nil { missing = append(missing, "api.services") } else { services, _ := api["services"].([]interface{}) if !sliceHasString(services, "StatsService") { missing = append(missing, "api.services StatsService") } } if asObject(raw["stats"]) == nil { missing = append(missing, "stats") } policy := asObject(raw["policy"]) levels := asObject(nil) level0 := asObject(nil) if policy != nil { levels = asObject(policy["levels"]) if levels != nil { level0 = asObject(levels["0"]) } } if level0 == nil { missing = append(missing, "policy.levels.0") } else { if v, _ := level0["statsUserUplink"].(bool); !v { missing = append(missing, "policy.levels.0.statsUserUplink") } if v, _ := level0["statsUserDownlink"].(bool); !v { missing = append(missing, "policy.levels.0.statsUserDownlink") } } inbounds, _ := raw["inbounds"].([]interface{}) apiInbound := findObjectByTag(inbounds, "api") if apiInbound == nil { missing = append(missing, "api inbound") } outbounds, _ := raw["outbounds"].([]interface{}) if findObjectByTag(outbounds, "api") == nil { missing = append(missing, "api outbound") } if n := countXrayClientEmailIssues(raw); n > 0 { missing = append(missing, fmt.Sprintf("%d client stats labels", n)) } routing := asObject(raw["routing"]) if routing == nil { missing = append(missing, "routing api rule") } else { rules, _ := routing["rules"].([]interface{}) if !hasAPIRoutingRule(rules) { missing = append(missing, "routing api rule") } } return xrayStatsConfigCheck{Configured: len(missing) == 0, Missing: missing, APIServer: discoverAPIServerFromRaw(raw)} } func (m *XrayManager) discoverAPIServerFromRaw(raw map[string]interface{}) string { return discoverAPIServerFromRaw(raw) } func discoverAPIServerFromRaw(raw map[string]interface{}) string { inbounds, _ := raw["inbounds"].([]interface{}) for _, item := range inbounds { ib, ok := item.(map[string]interface{}) if !ok { continue } tag, _ := ib["tag"].(string) proto, _ := ib["protocol"].(string) if tag != "api" || !strings.EqualFold(proto, "dokodemo-door") { continue } host, _ := ib["listen"].(string) host = strings.TrimSpace(host) if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } port := strings.TrimSpace(fmt.Sprint(ib["port"])) port = strings.Trim(port, `"`) if port == "" || port == "" || port == "null" { continue } return host + ":" + port } return "" } func countXrayClientEmailIssues(raw map[string]interface{}) int { inbounds, _ := raw["inbounds"].([]interface{}) seen := map[string]int{} issues := 0 for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) if !ok { continue } proto, _ := ibMap["protocol"].(string) if !xrayClientProtos[strings.ToLower(proto)] { continue } settings, _ := ibMap["settings"].(map[string]interface{}) if settings == nil { continue } clients, _ := settings["clients"].([]interface{}) for _, item := range clients { cm, ok := item.(map[string]interface{}) if !ok { continue } email := safeXrayStatsEmail(firstNonEmptyString(cm["email"])) if email == "" || seen[email] > 0 { issues++ } if email != "" { seen[email]++ } } } return issues } func ensureXrayClientEmails(raw map[string]interface{}) bool { changed := false inbounds, _ := raw["inbounds"].([]interface{}) seen := map[string]int{} for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) if !ok { continue } proto, _ := ibMap["protocol"].(string) if !xrayClientProtos[strings.ToLower(proto)] { continue } settings, _ := ibMap["settings"].(map[string]interface{}) if settings == nil { continue } clients, _ := settings["clients"].([]interface{}) for idx, item := range clients { cm, ok := item.(map[string]interface{}) if !ok { continue } email := safeXrayStatsEmail(firstNonEmptyString(cm["email"])) if email == "" || seen[email] > 0 { base := firstNonEmptyString(cm["name"], cm["email"], cm["id"], cm["password"]) if base == "" { base = fmt.Sprintf("client-%d", idx+1) } email = uniqueXrayEmail(base, seen) cm["email"] = email changed = true } if _, ok := cm["level"]; !ok { cm["level"] = float64(0) changed = true } seen[email]++ } } return changed } func firstNonEmptyString(values ...interface{}) string { for _, v := range values { s := strings.TrimSpace(fmt.Sprint(v)) if s != "" && s != "" { return s } } return "" } func uniqueXrayEmail(base string, seen map[string]int) string { email := safeXrayStatsEmail(base) if email == "" { email = "xray-client" } if len(email) > 40 { email = email[:40] email = strings.Trim(email, "-_.") if email == "" { email = "xray-client" } } candidate := email for i := 2; seen[candidate] > 0; i++ { candidate = fmt.Sprintf("%s-%d", email, i) } return candidate } func safeXrayStatsEmail(s string) string { s = strings.TrimSpace(s) if s == "" || s == "" { return "" } var b strings.Builder lastDash := false for _, r := range s { ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '@' || r == '.' || r == '_' || r == '-' if ok { b.WriteRune(r) lastDash = false } else if !lastDash { b.WriteByte('-') lastDash = true } } return strings.Trim(b.String(), "-_.") } func asObject(v interface{}) map[string]interface{} { m, _ := v.(map[string]interface{}) return m } func findObjectByTag(items []interface{}, tag string) map[string]interface{} { for _, item := range items { m, ok := item.(map[string]interface{}) if !ok { continue } if t, _ := m["tag"].(string); t == tag { return m } } return nil } func sliceHasString(items []interface{}, want string) bool { for _, item := range items { if s, ok := item.(string); ok && strings.EqualFold(s, want) { return true } } return false } func hasAPIRoutingRule(rules []interface{}) bool { for _, item := range rules { rule, ok := item.(map[string]interface{}) if !ok { continue } outbound, _ := rule["outboundTag"].(string) if outbound != "api" { continue } tags, ok := rule["inboundTag"].([]interface{}) if !ok { if tag, _ := rule["inboundTag"].(string); tag == "api" { return true } continue } if sliceHasString(tags, "api") { return true } } return false } // ---- Admin HTTP handlers ---- func handleXrayStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/status", nil, "") { return } // Be practical for old/manual configs: if the panel detects missing Stats API // pieces or missing per-client stat labels, repair once automatically. This // avoids a dashboard that says "OK" but continues to show zero Xray online // users until a technical admin manually edits JSON. if !xrayMgr.useNativeMode() { if check, err := xrayMgr.CheckStatsAPIConfig(); err == nil && !check.Configured { wasRunning := xrayMgr.isRunningSnapshot() if changed, err := xrayMgr.EnsureStatsAPIConfig(); err == nil && changed && wasRunning { if err := xrayMgr.Restart(); err != nil { xrayLogf("xray: auto stats repair restart failed: %v", err) } } } } status := xrayMgr.Status() if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && statsStore != nil { metas, err := statsStore.ListXrayClientsByOwner(r.Context(), sess.Username) if err == nil { status.OnlineUsers = 0 window := xrayMgr.onlineWindow() now := time.Now() for _, m := range metas { online := m.ActiveConnections > 0 || (m.LastActive != nil && now.Sub(*m.LastActive) <= window) if st, ok := xrayMgr.RuntimeStatsForKeys(m.Email, m.UUID, m.Name); ok { online = online || st.ActiveConnections > 0 || (!st.LastActive.IsZero() && now.Sub(st.LastActive) <= window) } if online { status.OnlineUsers++ } } } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(status) } func handleXrayStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/start", nil, "") { return } if err := xrayMgr.Start(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func handleXrayStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/stop", nil, "") { return } if err := xrayMgr.Stop(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func handleXrayRestart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/restart", nil, "") { return } if err := xrayMgr.Restart(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func handleXrayConfig(w http.ResponseWriter, r *http.Request) { if requestedServerID(r) != "" && requestedServerID(r) != "local" && requestedServerID(r) != "0" { body := []byte(nil) if r.Method == http.MethodPost { var err error body, err = io.ReadAll(io.LimitReader(r.Body, 512*1024)) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/config", body, "") { return } } switch r.Method { case http.MethodGet: data, err := xrayMgr.GetConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(data) case http.MethodPost: body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024)) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } if err := xrayMgr.SetConfig(body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/stats/repair", nil, "") { return } wasRunning := xrayMgr.isRunningSnapshot() changed, err := xrayMgr.EnsureStatsAPIConfig() if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } restarted := false if wasRunning { if err := xrayMgr.Restart(); err != nil { http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError) return } restarted = true } check, _ := xrayMgr.CheckStatsAPIConfig() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "changed": changed, "restarted": restarted, "stats_configured": check.Configured, "stats_missing": check.Missing, "api_server": check.APIServer, }) } func handleXrayLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/logs", nil, "") { return } lines := xrayLogBuf.snapshot() if lines == nil { lines = []string{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"lines": lines}) } // ---- Inbound / client management ---- // XrayClientInfo is a single client entry inside an Xray inbound. type XrayClientInfo struct { UUID string `json:"id"` Password string `json:"password,omitempty"` Email string `json:"email"` Level int `json:"level,omitempty"` // Runtime counters from the Xray stats API. Online means this user's // traffic counters changed inside the configured online window. Online bool `json:"online"` LastActive *time.Time `json:"last_active,omitempty"` UplinkBytes int64 `json:"uplink_bytes,omitempty"` DownlinkBytes int64 `json:"downlink_bytes,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"` ActiveConnections int `json:"active_connections,omitempty"` // Metadata from PostgreSQL (enriched by handleXrayInbounds) Name string `json:"name,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpirationDays int `json:"expiration_days"` MaxConns int `json:"max_conns"` OwnerUsername string `json:"owner_username,omitempty"` Expired bool `json:"expired,omitempty"` } // XrayInboundInfo is returned by /api/xray/inbounds. type XrayInboundInfo struct { Tag string `json:"tag"` Protocol string `json:"protocol"` Port json.RawMessage `json:"port,omitempty"` Listen string `json:"listen,omitempty"` Clients []XrayClientInfo `json:"clients"` } // protocols that carry a "clients" array in their settings var xrayClientProtos = map[string]bool{ "vless": true, "vmess": true, "trojan": true, } // ListInbounds parses the config and returns only inbounds that support client lists. func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) { m.mu.Lock() defer m.mu.Unlock() data, err := m.readConfigLocked() if err != nil { return nil, err } var cfg struct { Inbounds []json.RawMessage `json:"inbounds"` } if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse xray config: %w", err) } var result []XrayInboundInfo for _, raw := range cfg.Inbounds { var ib struct { Tag string `json:"tag"` Protocol string `json:"protocol"` Port json.RawMessage `json:"port"` Listen string `json:"listen"` Settings struct { Clients []XrayClientInfo `json:"clients"` } `json:"settings"` } if err := json.Unmarshal(raw, &ib); err != nil { continue } if !xrayClientProtos[strings.ToLower(ib.Protocol)] { continue } clients := ib.Settings.Clients if clients == nil { clients = []XrayClientInfo{} } for i := range clients { if clients[i].UUID == "" && clients[i].Password != "" { clients[i].UUID = clients[i].Password } } result = append(result, XrayInboundInfo{ Tag: ib.Tag, Protocol: strings.ToLower(ib.Protocol), Port: ib.Port, Listen: ib.Listen, Clients: clients, }) } if result == nil { result = []XrayInboundInfo{} } return result, nil } // modifyRawConfig reads the config as a generic map, calls fn to mutate it, then writes it back. // Caller must hold m.mu. func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error) error { data, err := m.readConfigLocked() if err != nil { return err } var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { return fmt.Errorf("parse xray config: %w", err) } if raw == nil { return fmt.Errorf("xray config must be a JSON object") } if err := fn(raw); err != nil { return err } out, err := json.MarshalIndent(raw, "", " ") if err != nil { return err } return m.writeConfigLocked(out) } // AddXrayClient adds a client to the named inbound and saves the config. func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error { m.mu.Lock() defer m.mu.Unlock() err := m.modifyRawConfig(func(raw map[string]interface{}) error { _, _ = ensureXrayStatsAPIConfig(raw) inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) if !ok { continue } if tag, _ := ibMap["tag"].(string); tag != inboundTag { continue } settings, _ := ibMap["settings"].(map[string]interface{}) if settings == nil { settings = make(map[string]interface{}) ibMap["settings"] = settings } clients, _ := settings["clients"].([]interface{}) for _, c := range clients { if cm, ok := c.(map[string]interface{}); ok { id, _ := cm["id"].(string) if id == "" { id, _ = cm["password"].(string) } if id == uuid { return fmt.Errorf("UUID %s already exists in inbound %s", uuid, inboundTag) } } } proto, _ := ibMap["protocol"].(string) client := map[string]interface{}{ "id": uuid, "email": email, "level": 0, } if strings.EqualFold(proto, "vmess") { client["alterId"] = 0 } settings["clients"] = append(clients, client) return nil } return fmt.Errorf("inbound %q not found", inboundTag) }) if err == nil && m.cfg != nil && m.cfg.UseNative() { if hotErr := nativeXray.addClient(inboundTag, uuid, email); hotErr != nil { xrayLogf("native xray: hot-add client %s to %s failed: %v", uuid, inboundTag, hotErr) } } return err } // RemoveXrayClient removes a client by UUID from the named inbound and saves the config. func (m *XrayManager) RemoveXrayClient(inboundTag, uuid string) error { m.mu.Lock() defer m.mu.Unlock() err := m.modifyRawConfig(func(raw map[string]interface{}) error { inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) if !ok { continue } if tag, _ := ibMap["tag"].(string); tag != inboundTag { continue } settings, _ := ibMap["settings"].(map[string]interface{}) if settings == nil { return fmt.Errorf("inbound %s has no settings", inboundTag) } clients, _ := settings["clients"].([]interface{}) var kept []interface{} removed := false for _, c := range clients { if cm, ok := c.(map[string]interface{}); ok { id, _ := cm["id"].(string) if id == "" { id, _ = cm["password"].(string) } if id == uuid { removed = true continue } } kept = append(kept, c) } if !removed { // DB-backed native users may exist only in xray_clients. The inbound // exists, so let the caller delete the database row too. return nil } settings["clients"] = kept return nil } return fmt.Errorf("inbound %q not found", inboundTag) }) if err == nil && m.cfg != nil && m.cfg.UseNative() { if hotErr := nativeXray.removeClient(inboundTag, uuid); hotErr != nil { xrayLogf("native xray: hot-remove client %s from %s failed: %v", uuid, inboundTag, hotErr) } } return err } // UpdateXrayClientEmail updates the client's email/stats label inside the Xray JSON config. // UUID remains immutable; metadata such as display name/expiry stays in PostgreSQL. func (m *XrayManager) UpdateXrayClientEmail(uuid, email string) error { if strings.TrimSpace(uuid) == "" || strings.TrimSpace(email) == "" { return nil } m.mu.Lock() defer m.mu.Unlock() err := m.modifyRawConfig(func(raw map[string]interface{}) error { changed := false inbounds, _ := raw["inbounds"].([]interface{}) for _, ib := range inbounds { ibMap, ok := ib.(map[string]interface{}) if !ok { continue } settings, _ := ibMap["settings"].(map[string]interface{}) clients, _ := settings["clients"].([]interface{}) for _, c := range clients { cm, ok := c.(map[string]interface{}) if !ok { continue } id, _ := cm["id"].(string) if id == "" { id, _ = cm["password"].(string) } if id == uuid { cm["email"] = email changed = true } } } if !changed { return nil } return nil }) if err == nil && m.cfg != nil && m.cfg.UseNative() { if hotErr := nativeXray.updateClientEmail(uuid, email); hotErr != nil { xrayLogf("native xray: hot-update client %s email failed: %v", uuid, hotErr) } } return err } // ---- HTTP handlers for inbound/client management ---- func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } filterOwner := "" if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller { filterOwner = sess.Username } if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/inbounds", nil, filterOwner) { return } inbounds, err := xrayMgr.ListInbounds() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sess := sessionFromCtx(r.Context()) isReseller := sess != nil && sess.Role == RoleReseller // Enrich clients with metadata from PostgreSQL when available. Resellers only // see their own Xray clients, but they still see all available inbounds so // they know where they can create new users. if statsStore != nil { metas, err := statsStore.ListAllXrayClients(r.Context()) if err != nil { if isReseller { for i := range inbounds { inbounds[i].Clients = []XrayClientInfo{} } } else { for i := range inbounds { for j := range inbounds[i].Clients { applyXrayRuntimeStats(&inbounds[i].Clients[j]) } } } } else { metaMap := make(map[string]*XrayClientMeta, len(metas)) metasByInbound := make(map[string][]*XrayClientMeta) for _, m := range metas { metaMap[m.UUID] = m metasByInbound[m.InboundTag] = append(metasByInbound[m.InboundTag], m) } now := time.Now() for i := range inbounds { seen := make(map[string]bool) filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients)+len(metasByInbound[inbounds[i].Tag])) appendMetaClient := func(m *XrayClientMeta) { if m == nil || m.UUID == "" || seen[m.UUID] { return } c := XrayClientInfo{ UUID: m.UUID, Email: m.Email, Name: m.Name, ExpiresAt: m.ExpiresAt, MaxConns: m.MaxConns, OwnerUsername: m.OwnerUsername, UplinkBytes: m.TotalUplinkBytes, DownlinkBytes: m.TotalDownlinkBytes, TotalBytes: m.TotalUplinkBytes + m.TotalDownlinkBytes, LastActive: m.LastActive, ActiveConnections: m.ActiveConnections, } if c.Email == "" { c.Email = m.UUID } if c.ActiveConnections > 0 { c.Online = true } else if c.LastActive != nil && now.Sub(*c.LastActive) <= xrayMgr.onlineWindow() { c.Online = true } applyXrayRuntimeStats(&c) if m.ExpiresAt == nil { c.ExpirationDays = -1 } else if m.ExpiresAt.Before(now) { c.Expired = true c.ExpirationDays = 0 } else { c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24) } seen[m.UUID] = true filtered = append(filtered, c) } for j := range inbounds[i].Clients { c := inbounds[i].Clients[j] applyXrayRuntimeStats(&c) m, ok := metaMap[c.UUID] if isReseller && (!ok || m.OwnerUsername != sess.Username) { continue } if !ok { c.ExpirationDays = -1 seen[c.UUID] = true filtered = append(filtered, c) continue } appendMetaClient(m) } for _, m := range metasByInbound[inbounds[i].Tag] { if isReseller && m.OwnerUsername != sess.Username { continue } appendMetaClient(m) } inbounds[i].Clients = filtered } } } else if isReseller { for i := range inbounds { inbounds[i].Clients = []XrayClientInfo{} } } else { for i := range inbounds { for j := range inbounds[i].Clients { applyXrayRuntimeStats(&inbounds[i].Clients[j]) } } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(inbounds) } func applyXrayRuntimeStats(c *XrayClientInfo) { if c == nil { return } st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name) if !ok { return } window := xrayMgr.onlineWindow() now := time.Now() if st.Uplink > c.UplinkBytes { c.UplinkBytes = st.Uplink } if st.Downlink > c.DownlinkBytes { c.DownlinkBytes = st.Downlink } c.TotalBytes = c.UplinkBytes + c.DownlinkBytes if st.ActiveConnections > c.ActiveConnections { c.ActiveConnections = st.ActiveConnections } if !st.LastActive.IsZero() { t := st.LastActive c.LastActive = &t } c.Online = c.ActiveConnections > 0 || (c.LastActive != nil && now.Sub(*c.LastActive) <= window) } func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { InboundTag string `json:"inbound_tag"` UUID string `json:"uuid"` Email string `json:"email"` Name string `json:"name"` ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty MaxConnections int `json:"max_connections"` OwnerUsername string `json:"owner_username,omitempty"` ServerID string `json:"server_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if req.InboundTag == "" || req.UUID == "" { http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) return } if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } else if remote { if !ms.EnableXray { http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden) return } if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller { req.OwnerUsername = sess.Username } req.ServerID = "" body, _ := json.Marshal(req) status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json") if err != nil { http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) return } writeProxyResponse(w, status, data, ct) return } req.Email = strings.TrimSpace(req.Email) if req.Email == "" { req.Email = strings.TrimSpace(req.Name) } if req.Email == "" { req.Email = req.UUID } sess := sessionFromCtx(r.Context()) ownerUsername := "" if sess != nil && sess.Role == RoleReseller { ownerUsername = sess.Username if statsStore == nil { http.Error(w, "storage not available", http.StatusInternalServerError) return } owner, ok := adminUsers.get(sess.Username) if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) { http.Error(w, "reseller account suspended or expired", http.StatusForbidden) return } if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers { http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) return } } else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" { ownerUsername = strings.TrimSpace(req.OwnerUsername) } if statsStore != nil { if _, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID); err == nil { http.Error(w, "UUID already exists in database", http.StatusBadRequest) return } else if err != sql.ErrNoRows { http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError) return } } if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if statsStore != nil { meta := XrayClientMeta{ UUID: req.UUID, Name: req.Name, Email: req.Email, InboundTag: req.InboundTag, OwnerUsername: ownerUsername, MaxConns: req.MaxConnections, } if req.ExpiresAt != "" { var t time.Time var err error for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { t, err = time.Parse(layout, req.ExpiresAt) if err == nil { break } } if err == nil { meta.ExpiresAt = &t } } if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { xrayLogf("xray: save meta for %s: %v", req.UUID, err) } } xrayMgr.restartIfExternalRunning() w.WriteHeader(http.StatusCreated) } // handleXrayClientUpdate updates DB metadata and mirrors the email/stats label // into the Xray JSON config when the client also exists there. func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { UUID string `json:"uuid"` Name string `json:"name"` Email string `json:"email"` ExpiresAt string `json:"expires_at"` MaxConnections int `json:"max_connections"` ServerID string `json:"server_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if req.UUID == "" { http.Error(w, "uuid required", http.StatusBadRequest) return } if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } else if remote { if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) { http.Error(w, "forbidden", http.StatusForbidden) return } req.ServerID = "" body, _ := json.Marshal(req) status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json") if err != nil { http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) return } writeProxyResponse(w, status, data, ct) return } if statsStore == nil { http.Error(w, "storage not available", http.StatusInternalServerError) return } existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID) if err != nil { http.Error(w, "client metadata not found", http.StatusNotFound) return } sess := sessionFromCtx(r.Context()) if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username { http.Error(w, "forbidden", http.StatusForbidden) return } meta := XrayClientMeta{ UUID: req.UUID, Name: req.Name, Email: req.Email, InboundTag: existing.InboundTag, OwnerUsername: existing.OwnerUsername, MaxConns: req.MaxConnections, } if req.ExpiresAt != "" { for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { if t, err := time.Parse(layout, req.ExpiresAt); err == nil { meta.ExpiresAt = &t break } } } if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError) return } if req.Email != "" { if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil { xrayLogf("xray: update config email for %s: %v", req.UUID, err) } else { xrayMgr.restartIfExternalRunning() } } w.WriteHeader(http.StatusOK) } func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } inboundTag := r.URL.Query().Get("inbound_tag") uuid := r.URL.Query().Get("uuid") if inboundTag == "" || uuid == "" { http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) return } if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } else if remote { if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) { http.Error(w, "forbidden", http.StatusForbidden) return } remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid) status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json") if err != nil { http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) return } writeProxyResponse(w, status, data, ct) return } sess := sessionFromCtx(r.Context()) if sess != nil && sess.Role == RoleReseller { if statsStore == nil { http.Error(w, "storage not available", http.StatusInternalServerError) return } meta, err := statsStore.GetXrayClientMeta(r.Context(), uuid) if err != nil || meta.OwnerUsername != sess.Username { http.Error(w, "forbidden", http.StatusForbidden) return } if meta.InboundTag != "" { inboundTag = meta.InboundTag } } if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if statsStore != nil { _ = statsStore.DeleteXrayClientMeta(r.Context(), uuid) } xrayMgr.restartIfExternalRunning() w.WriteHeader(http.StatusNoContent) }