Scope B (Windows agent): - probe.go split into shared core + probe_linux.go (verbatim Linux sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2 counted set, MachineGuid boot identity, registry facts; load and TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper) - update intents are acknowledged and skipped on Windows (the release assets are Linux binaries; Windows nodes update via install-agent.ps1) - cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/ uninstall/start/stop) mirroring main.go's agent lifecycle wiring - install-agent.ps1: downloads the exe, writes probeOnly agent.json, registers and starts the service, rerun-safe Scope C (version display): - config.GetAgentDisplayVersion (and the master getter) compose from the bare number so a V-form injection no longer doubles the letter (AV2.1.0 -> A2.1.0) - node list view repairs stored AV-prefixed reports at assembly so deployed agents read uniformly without an update Scope D (production fix): - share-link reality sni: when the inbound has a sing-box fragment, sni is the fragment's tls.server_name - the only name the sing-box REALITY server serves. The previous rule picked a uniformly random serverNames entry per generation, which xray tolerated but sing-box refuses as an invalid connection. sid keeps its random pick: the converter carries the whole shortIds list into the fragment, so every listed sid is served.
271 lines
7.4 KiB
Go
271 lines
7.4 KiB
Go
//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 <path>] 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
|
|
}
|