Files
3x-ui-agent/internal/agent/probe.go
T
Muse 75f10b27fa windows probe-only agent + agent version display unification + reality sni from sing-box fragment
Scope B (Windows agent):
- probe.go split into shared core + probe_linux.go (verbatim Linux
  sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes
  CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2
  counted set, MachineGuid boot identity, registry facts; load and
  TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper)
- update intents are acknowledged and skipped on Windows (the release
  assets are Linux binaries; Windows nodes update via install-agent.ps1)
- cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/
  uninstall/start/stop) mirroring main.go's agent lifecycle wiring
- install-agent.ps1: downloads the exe, writes probeOnly agent.json,
  registers and starts the service, rerun-safe

Scope C (version display):
- config.GetAgentDisplayVersion (and the master getter) compose from
  the bare number so a V-form injection no longer doubles the letter
  (AV2.1.0 -> A2.1.0)
- node list view repairs stored AV-prefixed reports at assembly so
  deployed agents read uniformly without an update

Scope D (production fix):
- share-link reality sni: when the inbound has a sing-box fragment,
  sni is the fragment's tls.server_name - the only name the sing-box
  REALITY server serves. The previous rule picked a uniformly random
  serverNames entry per generation, which xray tolerated but sing-box
  refuses as an invalid connection. sid keeps its random pick: the
  converter carries the whole shortIds list into the fragment, so
  every listed sid is served.
2026-10-23 13:22:51 +00:00

241 lines
7.1 KiB
Go

package agent
// probe.go is the platform-neutral core of the probe collector, ported
// from monitor-probe's agent collector (collect.rs, MIT): host Facts
// (once per process) and per-heartbeat Metrics with the source's
// accounting discipline:
// - memory "used" is free(1)'s column: total - available;
// - disk "used" is df(1)'s: real storage only, total - free blocks
// (not the unprivileged-available figure);
// - network totals sum only "counted" interfaces — loopback,
// container, tunnel and stacked devices are excluded by a filter so
// no byte is counted twice;
// - boot epoch = a stable boot identity + FNV-1a fingerprint of the
// counted set, so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable source yields
// a zero field, never a failed collection round.
//
// The samplers themselves are per-OS: probe_linux.go reads /proc and
// /sys, probe_windows.go reads the Win32 performance/registry surface.
// Both feed the shared rate/epoch machinery below and must keep the
// same accounting semantics; where a source has no equivalent on the
// platform, the field stays zero (documented at the sampler).
import (
"hash/fnv"
"net"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
// once and piggybacked on the register request and (cheaply) heartbeats.
type ProbeFacts struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Kernel string `json:"kernel"`
Arch string `json:"arch"`
Virt string `json:"virt"`
CPUName string `json:"cpuName"`
CPUCores uint32 `json:"cpuCores"`
MemTotal uint64 `json:"memTotal"`
SwapTotal uint64 `json:"swapTotal"`
DiskTotal uint64 `json:"diskTotal"`
AgentVersion string `json:"agentVersion"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
}
// ProbeMetrics mirrors monitor's Metrics frame: one sample per heartbeat.
type ProbeMetrics struct {
BootID string `json:"bootId"`
Iface string `json:"iface"`
Uptime uint64 `json:"uptime"`
CPU float32 `json:"cpu"`
Load [3]float32 `json:"load"`
MemTotal uint64 `json:"memTotal"`
MemUsed uint64 `json:"memUsed"`
SwapTotal uint64 `json:"swapTotal"`
SwapUsed uint64 `json:"swapUsed"`
DiskTotal uint64 `json:"diskTotal"`
DiskUsed uint64 `json:"diskUsed"`
NetRxTotal uint64 `json:"netRxTotal"`
NetTxTotal uint64 `json:"netTxTotal"`
NetRx uint64 `json:"netRx"`
NetTx uint64 `json:"netTx"`
TCP uint32 `json:"tcp"`
UDP uint32 `json:"udp"`
Procs uint32 `json:"procs"`
}
// probeCollector holds the cross-sample state the rates need (previous CPU
// counters and per-interface counters). Kept in memory only, like the source.
type probeCollector struct {
mu sync.Mutex
prevCPU *cpuSample
prevNet map[string][2]uint64
prevNetT time.Time
}
// cpuSample is one cumulative CPU reading in whatever tick unit the
// platform reports: total covers every state, idle only the idle state.
// The differencing in cpuPercent is unit-agnostic.
type cpuSample struct{ total, idle uint64 }
var (
probeColl = &probeCollector{}
factsOnce sync.Once
factsCache *ProbeFacts
)
// collectFacts returns the process-cached Facts, sampling on first use.
func collectFacts() *ProbeFacts {
factsOnce.Do(func() { factsCache = sampleFacts() })
return factsCache
}
// statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set
// selection. The pin bypasses the filter on purpose: the operator
// named this exact device, including when it is one the filter would
// skip (a tunnel the node actually bills on). A pinned name absent
// from the interface table counts nothing — the boot epoch changes
// with the counted set, so the master re-baselines instead of booking
// a phantom delta.
var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin.
func SetStatIfacePin(name string) { statIfacePin.Store(name) }
func statIfacePinValue() string {
if v := statIfacePin.Load(); v != nil {
return v.(string)
}
return ""
}
// netCounters is one interface's cumulative byte totals.
type netCounters struct {
name string
rx, tx uint64
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
return []netCounters{c}
}
}
return nil
}
// cpuPercent diffs two cumulative samples into a busy percentage. A
// missing or non-advancing previous sample reports 0 (the round only
// re-baselines) — the source's discipline on both platforms.
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
if now == nil {
return 0
}
prev := c.prevCPU
c.prevCPU = now
if prev == nil || now.total <= prev.total {
return 0
}
dt := float32(now.total - prev.total)
di := float32(0)
if now.idle > prev.idle {
di = float32(now.idle - prev.idle)
}
pct := (dt - di) / dt * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
return pct
}
// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
// The master only tests it for equality: any change re-baselines.
func bootEpoch(bootID string, names []string) string {
sorted := append([]string(nil), names...)
sort.Strings(sorted)
h := fnv.New64a()
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
return bootID + "/" + hex16(h.Sum64())
}
func hex16(v uint64) string {
const digits = "0123456789abcdef"
var buf [16]byte
for i := 15; i >= 0; i-- {
buf[i] = digits[v&0xf]
v >>= 4
}
return string(buf[:])
}
// netRate diffs per interface over those present in both samples
// (saturating), divided by the elapsed wall time.
func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64, uint64) {
prev, prevT := c.prevNet, c.prevNetT
next := make(map[string][2]uint64, len(counted))
for _, ct := range counted {
next[ct.name] = [2]uint64{ct.rx, ct.tx}
}
c.prevNet, c.prevNetT = next, now
if prev == nil {
return 0, 0
}
secs := now.Sub(prevT).Seconds()
if secs <= 0 {
return 0, 0
}
var rx, tx uint64
for _, ct := range counted {
p, ok := prev[ct.name]
if !ok {
continue
}
if ct.rx > p[0] {
rx += ct.rx - p[0]
}
if ct.tx > p[1] {
tx += ct.tx - p[1]
}
}
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
}
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
// v6 is only 2000::/3.
func isPublicIP(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
a, b, c := ip4[0], ip4[1], ip4[2]
switch {
case ip4.IsPrivate(), ip4.IsLoopback(), ip4.IsLinkLocalUnicast():
return false
case a == 0 || a >= 224:
return false
case a == 100 && b&0xc0 == 64:
return false
case a == 192 && b == 0 && c == 0:
return false
case a == 198 && b&0xfe == 18:
return false
}
return true
}
ip16 := ip.To16()
if ip16 == nil {
return false
}
return ip16[0]&0xe0 == 0x20
}