package xray import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "runtime" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/util/common" ) // GetBinaryName returns the Xray binary filename for the current OS and architecture. func GetBinaryName() string { return fmt.Sprintf("xray-%s-%s", runtime.GOOS, runtime.GOARCH) } // GetBinaryPath returns the full path to the Xray binary executable. func GetBinaryPath() string { return config.GetBinFolderPath() + "/" + GetBinaryName() } // GetConfigPath returns the path to the Xray configuration file in the binary folder. func GetConfigPath() string { return config.GetBinFolderPath() + "/config.json" } // GetGeositePath returns the path to the geosite data file used by Xray. func GetGeositePath() string { return config.GetBinFolderPath() + "/geosite.dat" } // GetGeoipPath returns the path to the geoip data file used by Xray. func GetGeoipPath() string { return config.GetBinFolderPath() + "/geoip.dat" } // GetIPLimitLogPath returns the path to the IP limit log file. func GetIPLimitLogPath() string { return config.GetLogFolder() + "/3xipl.log" } // GetIPLimitBannedLogPath returns the path to the banned IP log file. func GetIPLimitBannedLogPath() string { return config.GetLogFolder() + "/3xipl-banned.log" } // GetIPLimitBannedPrevLogPath returns the path to the previous banned IP log file. func GetIPLimitBannedPrevLogPath() string { return config.GetLogFolder() + "/3xipl-banned.prev.log" } // GetAccessPersistentLogPath returns the path to the persistent access log file. func GetAccessPersistentLogPath() string { return config.GetLogFolder() + "/3xipl-ap.log" } // GetAccessPersistentPrevLogPath returns the path to the previous persistent access log file. func GetAccessPersistentPrevLogPath() string { return config.GetLogFolder() + "/3xipl-ap.prev.log" } // GetAccessLogPath reads the Xray config and returns the access log file path. func GetAccessLogPath() (string, error) { config, err := os.ReadFile(GetConfigPath()) if err != nil { logger.Warningf("Failed to read configuration file: %s", err) return "", err } jsonConfig := map[string]any{} err = json.Unmarshal([]byte(config), &jsonConfig) if err != nil { logger.Warningf("Failed to parse JSON configuration: %s", err) return "", err } if jsonConfig["log"] != nil { jsonLog := jsonConfig["log"].(map[string]any) if jsonLog["access"] != nil { accessLogPath := jsonLog["access"].(string) return accessLogPath, nil } } return "", err } // stopProcess calls Stop on the given Process instance. func stopProcess(p *Process) { p.Stop() } // Process wraps an Xray process instance and provides management methods. type Process struct { *process } // NewProcess creates a new Xray process and sets up cleanup on garbage collection. func NewProcess(xrayConfig *Config) *Process { p := &Process{newProcess(xrayConfig)} runtime.SetFinalizer(p, stopProcess) return p } // NewTestProcess creates a new Xray process that uses a specific config file path. // Used for test runs (e.g. outbound test) so the main config.json is not overwritten. // The config file at configPath is removed when the process is stopped. func NewTestProcess(xrayConfig *Config, configPath string) *Process { p := &Process{newTestProcess(xrayConfig, configPath)} runtime.SetFinalizer(p, stopProcess) return p } type process struct { cmd *exec.Cmd done chan struct{} version string apiPort int onlineClients []string // nodeOnlineClients holds the online-emails list reported by each // remote node, keyed by node id. NodeTrafficSyncJob populates entries // per cron tick and clears them when a node's probe fails. The mutex // guards both this map and onlineClients above so GetOnlineClients // can build the union without a torn read. nodeOnlineClients map[int][]string onlineMu sync.RWMutex config *Config configPath string // if set, use this path instead of GetConfigPath() and remove on Stop logWriter *LogWriter exitErr error startTime time.Time intentionalStop atomic.Bool } var ( xrayGracefulStopTimeout = 5 * time.Second xrayForceStopTimeout = 2 * time.Second ) // newProcess creates a new internal process struct for Xray. func newProcess(config *Config) *process { return &process{ version: "Unknown", config: config, logWriter: NewLogWriter(), startTime: time.Now(), } } // newTestProcess creates a process that writes and runs with a specific config path. func newTestProcess(config *Config, configPath string) *process { p := newProcess(config) p.configPath = configPath return p } // IsRunning returns true if the Xray process is currently running. func (p *process) IsRunning() bool { if p.cmd == nil || p.cmd.Process == nil { return false } if p.done != nil { select { case <-p.done: return false default: } } if p.cmd.ProcessState == nil { return true } return false } // GetErr returns the last error encountered by the Xray process. func (p *process) GetErr() error { return p.exitErr } // GetResult returns the last log line or error from the Xray process. func (p *process) GetResult() string { if len(p.logWriter.lastLine) == 0 && p.exitErr != nil { return p.exitErr.Error() } return p.logWriter.lastLine } // GetVersion returns the version string of the Xray process. func (p *process) GetVersion() string { return p.version } // GetAPIPort returns the API port used by the Xray process. func (p *Process) GetAPIPort() int { return p.apiPort } // GetConfig returns the configuration used by the Xray process. func (p *Process) GetConfig() *Config { return p.config } // GetOnlineClients returns the union of locally-online clients and // node-online clients from every registered remote panel. Dedupes by // email so a client connected to both a local and a node-managed inbound // surfaces once. Cheap allocation — typical online sets are small and // the union is recomputed on demand. func (p *Process) GetOnlineClients() []string { p.onlineMu.RLock() defer p.onlineMu.RUnlock() if len(p.nodeOnlineClients) == 0 { // Hot path for single-panel deployments: avoid the map+dedupe // work entirely and return the local slice as-is. return p.onlineClients } seen := make(map[string]struct{}, len(p.onlineClients)) out := make([]string, 0, len(p.onlineClients)) for _, email := range p.onlineClients { if _, dup := seen[email]; dup { continue } seen[email] = struct{}{} out = append(out, email) } for _, list := range p.nodeOnlineClients { for _, email := range list { if _, dup := seen[email]; dup { continue } seen[email] = struct{}{} out = append(out, email) } } return out } // SetOnlineClients sets the locally-online list. Called by the local // XrayTrafficJob after each xray gRPC stats poll. func (p *Process) SetOnlineClients(users []string) { p.onlineMu.Lock() p.onlineClients = users p.onlineMu.Unlock() } // SetNodeOnlineClients records the online-emails set for one remote // node. Replaces any previous entry for that node — NodeTrafficSyncJob // always sends the full list per tick. func (p *Process) SetNodeOnlineClients(nodeID int, emails []string) { p.onlineMu.Lock() defer p.onlineMu.Unlock() if p.nodeOnlineClients == nil { p.nodeOnlineClients = map[int][]string{} } p.nodeOnlineClients[nodeID] = emails } // ClearNodeOnlineClients drops a node's contribution to the online set. // Called when a probe fails so a downed node doesn't keep its clients // listed as "online" until the next successful probe. func (p *Process) ClearNodeOnlineClients(nodeID int) { p.onlineMu.Lock() defer p.onlineMu.Unlock() delete(p.nodeOnlineClients, nodeID) } // GetUptime returns the uptime of the Xray process in seconds. func (p *Process) GetUptime() uint64 { return uint64(time.Since(p.startTime).Seconds()) } // refreshAPIPort updates the API port from the inbound configs. func (p *process) refreshAPIPort() { for _, inbound := range p.config.InboundConfigs { if inbound.Tag == "api" { p.apiPort = inbound.Port break } } } // refreshVersion updates the version string by running the Xray binary with -version. func (p *process) refreshVersion() { cmd := exec.Command(GetBinaryPath(), "-version") data, err := cmd.Output() if err != nil { p.version = "Unknown" } else { datas := bytes.Split(data, []byte(" ")) if len(datas) <= 1 { p.version = "Unknown" } else { p.version = string(datas[1]) } } } // Start launches the Xray process with the current configuration. func (p *process) Start() (err error) { if p.IsRunning() { return errors.New("xray is already running") } defer func() { if err != nil { logger.Error("Failure in running xray-core process: ", err) p.exitErr = err } }() data, err := json.MarshalIndent(p.config, "", " ") if err != nil { return common.NewErrorf("Failed to generate XRAY configuration files: %v", err) } err = os.MkdirAll(config.GetLogFolder(), 0o770) if err != nil { logger.Warningf("Failed to create log folder: %s", err) } configPath := GetConfigPath() if p.configPath != "" { configPath = p.configPath } err = os.WriteFile(configPath, data, 0644) if err != nil { return common.NewErrorf("Failed to write configuration file: %v", err) } cmd := exec.Command(GetBinaryPath(), "-c", configPath) cmd.Stdout = p.logWriter cmd.Stderr = p.logWriter err = p.startCommand(cmd) if err != nil { return err } p.refreshVersion() p.refreshAPIPort() return nil } func (p *process) startCommand(cmd *exec.Cmd) error { p.cmd = cmd p.done = make(chan struct{}) p.exitErr = nil p.intentionalStop.Store(false) if err := cmd.Start(); err != nil { close(p.done) p.cmd = nil return err } go p.waitForCommand(cmd) return nil } func (p *process) waitForCommand(cmd *exec.Cmd) { defer close(p.done) err := cmd.Wait() if err == nil || p.intentionalStop.Load() { return } // On Windows, killing the process results in "exit status 1" which isn't an error for us. if runtime.GOOS == "windows" { errStr := strings.ToLower(err.Error()) if strings.Contains(errStr, "exit status 1") { p.exitErr = err return } } logger.Error("Failure in running xray-core:", err) p.exitErr = err } // Stop terminates the running Xray process. func (p *process) Stop() error { if !p.IsRunning() { return errors.New("xray is not running") } p.intentionalStop.Store(true) // Remove temporary config file used for test runs so main config is never touched if p.configPath != "" { if p.configPath != GetConfigPath() { // Check if file exists before removing if _, err := os.Stat(p.configPath); err == nil { _ = os.Remove(p.configPath) } } } if runtime.GOOS == "windows" { if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { return err } return p.waitForExit(xrayForceStopTimeout) } if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { if errors.Is(err, os.ErrProcessDone) { return p.waitForExit(xrayForceStopTimeout) } return err } if err := p.waitForExit(xrayGracefulStopTimeout); err == nil { return nil } logger.Warning("xray-core did not stop after SIGTERM, killing process") if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { return err } return p.waitForExit(xrayForceStopTimeout) } func (p *process) waitForExit(timeout time.Duration) error { if p.done == nil { return nil } timer := time.NewTimer(timeout) defer timer.Stop() select { case <-p.done: return nil case <-timer.C: return common.NewErrorf("timed out waiting for xray-core process to stop after %s", timeout) } } // writeCrashReport writes a crash report to the binary folder with a timestamped filename. func writeCrashReport(m []byte) error { crashReportPath := config.GetBinFolderPath() + "/core_crash_" + time.Now().Format("20060102_150405") + ".log" return os.WriteFile(crashReportPath, m, 0644) }