mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
- Update go.mod module path from mhsanaei/3x-ui/v3 to saeederamy/3x-ui/v3 - Update all 73 Go files' import paths accordingly - Fix README.fa_IR.md install command to point to fork's main branch The fork was referencing the original repo's module path in go.mod and all Go source imports, making it dependent on MHSanaei's namespace at build time. https://claude.ai/code/session_01M6d5atbWjuLTj6UwRHoK5m
458 lines
12 KiB
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/saeederamy/3x-ui/v3/config"
|
|
"github.com/saeederamy/3x-ui/v3/logger"
|
|
"github.com/saeederamy/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)
|
|
}
|