agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/ uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底); 主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变; agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值, panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
204 lines
5.2 KiB
Go
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)
|
|
}
|