Files
l-ui/internal/agent/telemetry.go
T
Muse 1edb8f71da feat(version): 第18条 显示版本与内部版本解耦(V/A 双系)
- config 新增 displayVersion 注入变量(默认 1.0.0):GetDisplayVersion/
  GetMasterDisplayVersion(V)/GetAgentDisplayVersion(A),以后升版只改注入值
- ParseDisplayVersion/CompareDisplayVersion:三段数值比较,个位进位规则
  (1.0.9<1.1.0) 天然成立;不可解析(dev 标签/3.7.0-agent.1)返回 ok=false
- 显示位切换:dist 注入 X_UI_CUR_VER、-v、TG 报告 → V 系;
  agent 遥测 agentVersion → A 系;节点行 A 系原样显示、老 agent 沿用
  "agent " 前缀兼容
- 内部流不动:GetBaseVersion/GetPanelVersion 及其比较/订阅/远端状态
  消费方保持原样,传统节点更新判定与徽标行为不变
2026-10-22 13:01:50 +00:00

206 lines
5.3 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 {
// Agents report the A-series display version; the master shows it
// verbatim on agent node rows.
t := &telemetryPayload{AgentVersion: config.GetAgentDisplayVersion()}
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)
}