package xray import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "sync" "time" "vpnclient/internal/config" ) // Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan. type Engine struct { mu sync.Mutex cmd *exec.Cmd cancel context.CancelFunc done chan struct{} workdir string profile config.Profile stderr io.Writer running bool proto config.Protocol } func New(stderr io.Writer) *Engine { if stderr == nil { stderr = os.Stderr } return &Engine{stderr: stderr} } func (e *Engine) Protocol() config.Protocol { if e.proto != "" { return e.proto } return config.ProtocolVLESS } func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir string) error { e.mu.Lock() defer e.mu.Unlock() if e.running { return fmt.Errorf("xray: already running") } link, err := Parse(profile.Proxy) if err != nil { return err } e.proto = link.Protocol bin, err := ResolveBinary(binDir) if err != nil { return err } workdir, err := os.MkdirTemp("", "vpnclient-xray-*") if err != nil { return fmt.Errorf("xray: temp dir: %w", err) } cfgPath := filepath.Join(workdir, "config.json") if err := writeRuntimeConfig(cfgPath, profile); err != nil { os.RemoveAll(workdir) return err } runCtx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(runCtx, bin, "run", "-c", cfgPath) cmd.Dir = workdir cmd.Stdout = e.stderr cmd.Stderr = e.stderr applySysProcAttr(cmd) if err := cmd.Start(); err != nil { cancel() os.RemoveAll(workdir) return fmt.Errorf("xray: start %s: %w", bin, err) } done := make(chan struct{}) e.cmd = cmd e.cancel = cancel e.done = done e.workdir = workdir e.profile = profile e.running = true go func() { err := cmd.Wait() if err != nil { fmt.Fprintf(e.stderr, "xray: process ended: %v\n", err) } e.mu.Lock() e.cleanupLocked() e.mu.Unlock() close(done) }() e.mu.Unlock() timer := time.NewTimer(900 * time.Millisecond) defer timer.Stop() var startErr error select { case <-done: startErr = fmt.Errorf("xray: process exited immediately; check link and install-core (xray)") case <-ctx.Done(): _ = e.Stop() startErr = ctx.Err() case <-timer.C: e.mu.Lock() alive := e.running e.mu.Unlock() if !alive { startErr = fmt.Errorf("xray: process exited during startup") } } e.mu.Lock() return startErr } func (e *Engine) Stop() error { e.mu.Lock() defer e.mu.Unlock() return e.stopLocked() } func (e *Engine) stopLocked() error { if !e.running || e.cmd == nil { return nil } done := e.done if e.cancel != nil { e.cancel() } proc := e.cmd.Process e.mu.Unlock() select { case <-done: case <-time.After(2 * time.Second): if proc != nil { _ = proc.Kill() } <-done } e.mu.Lock() return nil } func (e *Engine) cleanupLocked() { if e.workdir != "" { _ = os.RemoveAll(e.workdir) e.workdir = "" } e.cmd = nil e.cancel = nil e.running = false } func (e *Engine) Running() bool { e.mu.Lock() defer e.mu.Unlock() return e.running } func (e *Engine) LocalHTTPProxy() (string, bool) { e.mu.Lock() defer e.mu.Unlock() if !e.running { return "", false } return e.profile.HTTPListenHostPort() } func (e *Engine) LocalSOCKSProxy() (string, bool) { e.mu.Lock() defer e.mu.Unlock() if !e.running { return "", false } return e.profile.SOCKSListenHostPort() }