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。
This commit is contained in:
+28
-4
@@ -14,6 +14,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
@@ -64,6 +66,15 @@ type Agent struct {
|
||||
xrayAPI xray.XrayAPI
|
||||
apiPort int
|
||||
baselineLoaded bool
|
||||
|
||||
// Heartbeat telemetry state (P13): last measured master-request RTT
|
||||
// and the mtime-keyed xray version cache.
|
||||
lastRTTms atomic.Int64
|
||||
hasRTT atomic.Bool
|
||||
xrayVerMu sync.Mutex
|
||||
xrayVer string
|
||||
xrayVerFor string // binary path the cached version was read from
|
||||
xrayVerMod time.Time
|
||||
}
|
||||
|
||||
// New creates a new agent instance
|
||||
@@ -110,7 +121,7 @@ func (a *Agent) Register() error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
resp, err := a.do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register failed: %w", err)
|
||||
}
|
||||
@@ -241,12 +252,25 @@ func (a *Agent) Stop() {
|
||||
close(a.stopCh)
|
||||
}
|
||||
|
||||
// do performs one master request and records its round-trip time as the
|
||||
// node->master latency sample reported by the next heartbeat (P13).
|
||||
func (a *Agent) do(req *http.Request) (*http.Response, error) {
|
||||
start := time.Now()
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err == nil {
|
||||
a.lastRTTms.Store(time.Since(start).Milliseconds())
|
||||
a.hasRTT.Store(true)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the master
|
||||
func (a *Agent) sendHeartbeat() error {
|
||||
url := fmt.Sprintf("%s/panel/api/agent/heartbeat", a.config.MasterURL)
|
||||
payload := map[string]interface{}{
|
||||
"token": a.config.Token,
|
||||
"timestamp": time.Now().Unix(),
|
||||
"telemetry": a.collectTelemetry(),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
@@ -256,7 +280,7 @@ func (a *Agent) sendHeartbeat() error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
resp, err := a.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -276,7 +300,7 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
resp, err := a.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -509,7 +533,7 @@ func (a *Agent) postTrafficReport(report trafficReport) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
resp, err := a.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
func TestParseXrayVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"Xray 26.6.27 (Xray, Penetrates Everything.) 45cf289 (go1.26.4 linux/amd64)\nA unified platform for anti-censorship.", "26.6.27", true},
|
||||
{"Xray 1.8.4", "1.8.4", true},
|
||||
{"V2Ray 4.45.4 (V2Fly, a community-driven edition of V2Ray.)", "", false},
|
||||
{"", "", false},
|
||||
{"Xray", "", false},
|
||||
{"not a version banner", "", false},
|
||||
{"Xray (no version here)", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseXrayVersion(c.in)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("parseXrayVersion(%q) = %q,%v want %q,%v", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseHostIPs(t *testing.T) {
|
||||
mk := func(ss ...string) []net.IP {
|
||||
var out []net.IP
|
||||
for _, s := range ss {
|
||||
out = append(out, net.ParseIP(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in []net.IP
|
||||
v4 string
|
||||
v6 string
|
||||
}{
|
||||
{"public wins over private", mk("10.0.0.5", "8.8.4.4", "2001:db8::1", "fd00::1"), "8.8.4.4", "2001:db8::1"},
|
||||
{"private fallback", mk("192.168.1.2", "fd00::9"), "192.168.1.2", "fd00::9"},
|
||||
{"loopback and link-local excluded", mk("127.0.0.1", "169.254.1.1", "fe80::1", "::1"), "", ""},
|
||||
{"empty", nil, "", ""},
|
||||
{"multicast excluded", mk("224.0.0.1", "ff02::1"), "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v4, v6 := chooseHostIPs(c.in)
|
||||
if v4 != c.v4 || v6 != c.v6 {
|
||||
t.Errorf("%s: chooseHostIPs = %q,%q want %q,%q", c.name, v4, v6, c.v4, c.v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectTelemetryShape pins the always-on fields on any Linux host:
|
||||
// the agent's own build version and a positive host uptime. Source-specific
|
||||
// fields (xray version, IPs) are environment-dependent and not asserted.
|
||||
func TestCollectTelemetryShape(t *testing.T) {
|
||||
a := New(&Config{MasterURL: "http://127.0.0.1:1", Token: "t", NodeName: "n"})
|
||||
tel := a.collectTelemetry()
|
||||
if tel.AgentVersion != config.GetBaseVersion() || tel.AgentVersion == "" {
|
||||
t.Errorf("AgentVersion = %q, want config.GetBaseVersion() %q", tel.AgentVersion, config.GetBaseVersion())
|
||||
}
|
||||
if tel.UptimeSeconds == nil || *tel.UptimeSeconds == 0 {
|
||||
t.Errorf("UptimeSeconds = %v, want non-nil positive", tel.UptimeSeconds)
|
||||
}
|
||||
if tel.MemPercent == nil || *tel.MemPercent < 0 || *tel.MemPercent > 100 {
|
||||
t.Errorf("MemPercent = %v, want within [0,100]", tel.MemPercent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user