Files
DragonCoreSSH-NewWEB/xray_integration.go
2026-05-11 14:32:16 -03:00

1833 lines
50 KiB
Go

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"])) == "<nil>" {
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"])) == "<nil>" {
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 == "<nil>" || 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 != "<nil>" {
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 == "<nil>" {
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)
}