1833 lines
50 KiB
Go
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)
|
|
}
|