package xray import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "os" "os/exec" "runtime" "strings" "sync" "syscall" "time" "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/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 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 } // 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.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, fs.ModePerm) if err != nil { return common.NewErrorf("Failed to write configuration file: %v", err) } cmd := exec.Command(GetBinaryPath(), "-c", configPath) p.cmd = cmd cmd.Stdout = p.logWriter cmd.Stderr = p.logWriter go func() { err := cmd.Run() if err != nil { // 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") { // Suppress noisy log on graceful stop p.exitErr = err return } } logger.Error("Failure in running xray-core:", err) p.exitErr = err } }() p.refreshVersion() p.refreshAPIPort() return nil } // Stop terminates the running Xray process. func (p *process) Stop() error { if !p.IsRunning() { return errors.New("xray is not running") } // 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" { return p.cmd.Process.Kill() } else { return p.cmd.Process.Signal(syscall.SIGTERM) } } // 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, os.ModePerm) }