package agent import ( "context" "math" "net" "os" "os/exec" "path/filepath" "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/mem" ) // telemetryPayload is the best-effort host/runtime snapshot piggybacked on // every heartbeat (P13). Pointer fields are omitted when their collector // failed, so the master keeps the last stored value instead of a zero. type telemetryPayload struct { AgentVersion string `json:"agentVersion"` XrayVersion string `json:"xrayVersion,omitempty"` UptimeSeconds *uint64 `json:"uptimeSeconds,omitempty"` LatencyMs *int64 `json:"latencyMs,omitempty"` CpuPercent *float64 `json:"cpuPercent,omitempty"` MemPercent *float64 `json:"memPercent,omitempty"` IPv4 string `json:"ipv4,omitempty"` IPv6 string `json:"ipv6,omitempty"` } // collectTelemetry samples every source independently; a failing source // only blanks its own field, never the whole report. func (a *Agent) collectTelemetry() *telemetryPayload { t := &telemetryPayload{AgentVersion: config.GetBaseVersion()} if v, ok := a.cachedXrayVersion(); ok { t.XrayVersion = v } if up, err := host.Uptime(); err == nil { t.UptimeSeconds = &up } if pcts, err := cpu.Percent(0, false); err == nil && len(pcts) > 0 { v := math.Round(pcts[0]*10) / 10 t.CpuPercent = &v } if vm, err := mem.VirtualMemory(); err == nil { v := math.Round(vm.UsedPercent*10) / 10 t.MemPercent = &v } if a.hasRTT.Load() { v := a.lastRTTms.Load() t.LatencyMs = &v } t.IPv4, t.IPv6 = hostPublicIPs() return t } // cachedXrayVersion returns the managed xray binary's version, re-reading // it only when the binary file changed (path or mtime) since the last read, // so a swapped xray is picked up without restarting the agent. func (a *Agent) cachedXrayVersion() (string, bool) { path := xrayBinaryPath() if path == "" { return "", false } fi, err := os.Stat(path) if err != nil { return "", false } a.xrayVerMu.Lock() defer a.xrayVerMu.Unlock() if a.xrayVer != "" && a.xrayVerFor == path && fi.ModTime().Equal(a.xrayVerMod) { return a.xrayVer, true } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out, err := exec.CommandContext(ctx, path, "version").Output() if err != nil { return "", false } v, ok := parseXrayVersion(string(out)) if !ok { return "", false } a.xrayVer, a.xrayVerFor, a.xrayVerMod = v, path, fi.ModTime() return v, true } // xrayBinaryPath resolves the managed xray binary: the arch-specific name // the process manager uses, falling back to a plain "xray" in the same bin // folder (mirrors ensureXrayBinary). func xrayBinaryPath() string { if p := xray.GetBinaryPath(); fileExists(p) { return p } if p := filepath.Join(config.GetBinFolderPath(), "xray"); fileExists(p) { return p } return "" } func fileExists(p string) bool { fi, err := os.Stat(p) return err == nil && !fi.IsDir() } // parseXrayVersion extracts the version from the first line of // `xray version` output, e.g. "Xray 26.6.27 (Xray, Penetrates Everything.)". func parseXrayVersion(out string) (string, bool) { line := out if i := strings.IndexByte(line, '\n'); i >= 0 { line = line[:i] } fields := strings.Fields(line) if len(fields) < 2 || !strings.EqualFold(fields[0], "xray") { return "", false } v := fields[1] if v == "" || v[0] < '0' || v[0] > '9' { return "", false } return v, true } // hostPublicIPs picks one IPv4 and one IPv6 address from the host's active // interfaces. Loopback, link-local, multicast and down interfaces are // excluded; globally routable addresses win over private/ULA ones, which // are only used when no public address exists. func hostPublicIPs() (string, string) { ifaces, err := net.Interfaces() if err != nil { return "", "" } var ips []net.IP for _, ifc := range ifaces { if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 { continue } addrs, err := ifc.Addrs() if err != nil { continue } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP } if ip != nil { ips = append(ips, ip) } } } return chooseHostIPs(ips) } // chooseHostIPs applies the public-over-private preference to a candidate // address list. Kept pure for testing. func chooseHostIPs(ips []net.IP) (string, string) { var v4Pub, v4Priv, v6Pub, v6Priv net.IP for _, ip := range ips { if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { continue } if ip4 := ip.To4(); ip4 != nil { if ip4.IsPrivate() { if v4Priv == nil { v4Priv = ip4 } } else if v4Pub == nil { v4Pub = ip4 } continue } ip6 := ip.To16() if ip6 == nil { continue } if ip6.IsPrivate() { // unique-local fc00::/7 if v6Priv == nil { v6Priv = ip6 } } else if v6Pub == nil { v6Pub = ip6 } } pick := func(pub, priv net.IP) string { if pub != nil { return pub.String() } if priv != nil { return priv.String() } return "" } return pick(v4Pub, v4Priv), pick(v6Pub, v6Priv) }