package main import ( "context" "encoding/json" "fmt" "io" "log" "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"` // e.g. /opt/sshpanel/xray ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json // 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 } // xrayLogRing is a fixed-capacity circular buffer for captured log lines. type xrayLogRing struct { mu sync.Mutex lines []string pos int } const xrayLogCap = 200 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) } // 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 } type xrayTrafficCounters struct { Uplink int64 Downlink int64 } type xrayRuntimeStat struct { Email string Uplink int64 Downlink int64 LastActive time.Time } 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 xrayMgr.mu.Unlock() xrayMgr.startStatsPoller() if cfg.Enabled { if err := xrayMgr.Start(); err != nil { log.Printf("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.isRunning() { return fmt.Errorf("xray already running (pid %d)", m.cmd.Process.Pid) } if m.cfg == nil { return fmt.Errorf("xray not configured") } 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 { log.Printf("xray: repaired Stats API support in config before start") } args := []string{"run"} if m.cfg.ConfigFile != "" { args = append(args, "-c", m.cfg.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() log.Printf("xray: process exited: %v", err) }() log.Printf("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 { 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): } } log.Printf("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() } // XrayStatusDTO is returned by /api/xray/status. type XrayStatusDTO struct { Enabled bool `json:"enabled"` Running bool `json:"running"` 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.refreshRuntimeStatsIfStale(3 * time.Second) m.mu.Lock() s := XrayStatusDTO{} if m.cfg != nil { s.Enabled = m.cfg.Enabled } 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() 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.ConfigFile == "" { 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.ConfigFile == "" { return "" } data, err := os.ReadFile(m.cfg.ConfigFile) 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} 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.LastActive.IsZero() && now.Sub(st.LastActive) <= window { n++ } } return n } // GetConfig reads the current xray JSON config file. func (m *XrayManager) GetConfig() ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() if m.cfg == nil || m.cfg.ConfigFile == "" { return nil, fmt.Errorf("xray config file not configured") } return os.ReadFile(m.cfg.ConfigFile) } // SetConfig validates and atomically writes a new xray JSON config file. func (m *XrayManager) SetConfig(data []byte) error { m.mu.Lock() defer m.mu.Unlock() if m.cfg == nil || m.cfg.ConfigFile == "" { return fmt.Errorf("xray config file not configured") } patched, changed, err := patchXrayStatsAPIBytes(data) if err != nil { return err } if changed { log.Printf("xray: added/repaired Stats API support while saving config") } return os.WriteFile(m.cfg.ConfigFile, patched, 0o600) } 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) { if m.cfg == nil || m.cfg.ConfigFile == "" { return xrayStatsConfigCheck{}, fmt.Errorf("xray config file not configured") } data, err := os.ReadFile(m.cfg.ConfigFile) 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) { if m.cfg == nil || m.cfg.ConfigFile == "" { return false, fmt.Errorf("xray config file not configured") } data, err := os.ReadFile(m.cfg.ConfigFile) 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, os.WriteFile(m.cfg.ConfigFile, patched, 0o600) } 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 !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 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 { log.Printf("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 { st, ok := xrayMgr.RuntimeStatsForKeys(m.Email, m.UUID, m.Name) if ok && !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { 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"` // 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() if m.cfg == nil || m.cfg.ConfigFile == "" { return nil, fmt.Errorf("xray config file not configured") } data, err := os.ReadFile(m.cfg.ConfigFile) 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 { if m.cfg == nil || m.cfg.ConfigFile == "" { return fmt.Errorf("xray config file not configured") } data, err := os.ReadFile(m.cfg.ConfigFile) 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 os.WriteFile(m.cfg.ConfigFile, out, 0o600) } // 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() return 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 { if id, _ := cm["id"].(string); 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) }) } // 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() return 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 { if id, _ := cm["id"].(string); id == uuid { removed = true continue } } kept = append(kept, c) } if !removed { return fmt.Errorf("UUID %s not found in inbound %s", uuid, inboundTag) } settings["clients"] = kept return nil } return fmt.Errorf("inbound %q not found", inboundTag) }) } // ---- 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)) for _, m := range metas { metaMap[m.UUID] = m } now := time.Now() for i := range inbounds { filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients)) 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 filtered = append(filtered, c) continue } c.Name = m.Name c.ExpiresAt = m.ExpiresAt c.MaxConns = m.MaxConns c.OwnerUsername = m.OwnerUsername 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) } filtered = append(filtered, c) } 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() c.UplinkBytes = st.Uplink c.DownlinkBytes = st.Downlink c.TotalBytes = st.Uplink + st.Downlink if !st.LastActive.IsZero() { t := st.LastActive c.LastActive = &t c.Online = now.Sub(st.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 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 { log.Printf("xray: save meta for %s: %v", req.UUID, err) } } _ = xrayMgr.Restart() w.WriteHeader(http.StatusCreated) } // handleXrayClientUpdate updates the metadata (name, email, expiry, max_conns) // of an existing Xray client in PostgreSQL without touching the config file. 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 } 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.Restart() w.WriteHeader(http.StatusNoContent) }