//go:build windows // Command xui-agent-windows is the standalone Windows build of the // 3x-ui node agent. Windows nodes are probe-only by design: the agent // reports host facts/metrics and runs latency probes, but no xray or // sing-box core is ever fetched or started here (the agent core // machinery stays the master's Linux fleet concern; the local // agent.json carries "probeOnly": true, written by // install-agent.ps1). // // Usage: // // xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console) // xui-agent-windows.exe install [-config ] register the service // xui-agent-windows.exe uninstall remove the service // xui-agent-windows.exe start | stop control the service // // The agent lifecycle wiring (config load, baseline derivation, // logger init, graceful stop) mirrors main.go's agent branch — the // same internal/agent package does the work either way. package main import ( "flag" "fmt" "log" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/mhsanaei/3x-ui/v3/internal/agent" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/op/go-logging" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/mgr" ) const ( serviceName = "x-ui-agent" defaultCfgPath = `C:\x-ui\agent.json` ) func main() { log.SetPrefix("[x-ui-agent] ") args := os.Args[1:] // Management subcommands come first; anything else is a run. if len(args) > 0 { switch args[0] { case "install", "uninstall", "start", "stop": cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:]) if err := runControl(args[0], cfgPath); err != nil { log.Fatalf("%s: %v", args[0], err) } return } } cfgPath := parseConfigFlag("xui-agent-windows", args) isService, err := svc.IsWindowsService() if err != nil { log.Fatalf("detect service environment: %v", err) } if isService { if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil { log.Fatalf("service run: %v", err) } return } // Interactive run: console foreground, Ctrl+C stops gracefully. running, err := startAgent(cfgPath) if err != nil { log.Fatalf("start agent: %v", err) } sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) select { case <-sigCh: log.Println("Shutting down agent...") running.Stop() <-running.done case err := <-running.done: if err != nil { log.Fatalf("Agent error: %v", err) } } } func parseConfigFlag(name string, args []string) string { fs := flag.NewFlagSet(name, flag.ExitOnError) cfg := fs.String("config", defaultCfgPath, "Path to agent config file") _ = fs.Parse(args) return *cfg } // runningAgent is one started agent plus its completion signal. type runningAgent struct { ag *agent.Agent done chan error } func (r *runningAgent) Stop() { r.ag.Stop() } // startAgent performs the same setup as main.go's agent branch and // launches the agent loop in the background. func startAgent(cfgPath string) (*runningAgent, error) { // Agent mode uses the panel's directory layout for core binaries // and configs; on a probe-only Windows node nothing consumes it, // but keep the wiring identical to the Linux entry so behaviour // cannot drift between the two. if os.Getenv("XUI_BIN_FOLDER") == "" { _ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder) } cfg, err := agent.LoadConfig(cfgPath) if err != nil { return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err) } // The acknowledged traffic baseline persists next to the agent // config (its state home), unless the config names a path. if cfg.BaselinePath == "" { cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json") } logger.InitLogger(logging.INFO) log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName) ag := agent.New(cfg) r := &runningAgent{ag: ag, done: make(chan error, 1)} go func() { r.done <- ag.Run() }() return r, nil } // agentHandler adapts the agent loop to the SCM protocol. type agentHandler struct { cfgPath string } func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { const accepted = svc.AcceptStop | svc.AcceptShutdown status <- svc.Status{State: svc.StartPending} running, err := startAgent(h.cfgPath) if err != nil { log.Printf("start agent: %v", err) return false, 1 } status <- svc.Status{State: svc.Running, Accepts: accepted} for { select { case err := <-running.done: // The agent loop ended on its own (e.g. fatal run error). if err != nil { log.Printf("agent exited: %v", err) return false, 1 } return false, 0 case c := <-r: switch c.Cmd { case svc.Interrogate: status <- c.CurrentStatus case svc.Stop, svc.Shutdown: status <- svc.Status{State: svc.StopPending} running.Stop() <-running.done return false, 0 default: log.Printf("unexpected service command: %v", c.Cmd) } } } } // runControl implements the install/uninstall/start/stop subcommands // through the service control manager. func runControl(cmd, cfgPath string) error { m, err := mgr.ConnectRemote("") if err != nil { return fmt.Errorf("connect to service manager: %w", err) } defer m.Disconnect() switch cmd { case "install": exePath, err := os.Executable() if err != nil { return fmt.Errorf("locate own binary: %w", err) } absCfg, err := filepath.Abs(cfgPath) if err != nil { return fmt.Errorf("resolve config path: %w", err) } if s, err := m.OpenService(serviceName); err == nil { s.Close() return fmt.Errorf("service %s already exists (uninstall first)", serviceName) } s, err := m.CreateService(serviceName, exePath, mgr.Config{ DisplayName: "3x-ui Agent", Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).", StartType: mgr.StartAutomatic, ErrorControl: mgr.ErrorNormal, }, "-config", absCfg) if err != nil { return fmt.Errorf("create service: %w", err) } defer s.Close() log.Printf("service %s installed (config: %s)", serviceName, absCfg) return nil case "uninstall": s, err := openService(m) if err != nil { return err } defer s.Close() // Stop first so the binary is not locked when the operator // deletes or replaces it; an already-stopped service is fine. _, _ = s.Control(svc.Stop) deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { st, err := s.Query() if err != nil || st.State == svc.Stopped { break } time.Sleep(300 * time.Millisecond) } if err := s.Delete(); err != nil { return fmt.Errorf("delete service: %w", err) } log.Printf("service %s removed", serviceName) return nil case "start": s, err := openService(m) if err != nil { return err } defer s.Close() if err := s.Start(); err != nil { return fmt.Errorf("start service: %w", err) } log.Printf("service %s started", serviceName) return nil case "stop": s, err := openService(m) if err != nil { return err } defer s.Close() if _, err := s.Control(svc.Stop); err != nil { return fmt.Errorf("stop service: %w", err) } log.Printf("service %s stop requested", serviceName) return nil } return fmt.Errorf("unknown subcommand %q", cmd) } func openService(m *mgr.Mgr) (*mgr.Service, error) { s, err := m.OpenService(serviceName) if err != nil { return nil, fmt.Errorf("open service %s: %w", serviceName, err) } return s, nil }