Files
3x-ui-agent/internal/agent/telemetry.go
T
Muse 3536d4174e feat(telemetry): agent 心跳遥测上报+节点列表覆写(第13条)
agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/
uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底);
主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变;
agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值,
panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
2026-10-22 11:33:56 +00:00

204 lines
5.2 KiB
Go

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)
}