3x-ui/xray/process.go

458 lines
12 KiB
Go

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)
}