From 75f10b27fa7bd3d331444a6d25e8937dada3ad7a Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 23 Oct 2026 13:22:51 +0000 Subject: [PATCH] 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. --- cmd/xui-agent-windows/main.go | 270 ++++++ install-agent.ps1 | 102 +++ internal/agent/probe.go | 827 +----------------- internal/agent/probe_linux.go | 778 ++++++++++++++++ internal/agent/probe_test.go | 6 + internal/agent/probe_windows.go | 463 ++++++++++ internal/agent/probe_windows_test.go | 99 +++ internal/agent/update.go | 22 + internal/agent/update_test.go | 33 + internal/config/config.go | 18 +- internal/config/config_test.go | 14 + internal/sub/service.go | 38 +- internal/sub/service_sharelink_test.go | 54 ++ .../web/service/node_agent_telemetry_test.go | 22 + internal/web/service/node_contract.go | 33 +- 15 files changed, 1980 insertions(+), 799 deletions(-) create mode 100644 cmd/xui-agent-windows/main.go create mode 100644 install-agent.ps1 create mode 100644 internal/agent/probe_linux.go create mode 100644 internal/agent/probe_windows.go create mode 100644 internal/agent/probe_windows_test.go diff --git a/cmd/xui-agent-windows/main.go b/cmd/xui-agent-windows/main.go new file mode 100644 index 0000000..964819b --- /dev/null +++ b/cmd/xui-agent-windows/main.go @@ -0,0 +1,270 @@ +//go:build windows + +// Command xui-agent-windows is the standalone Windows build of the +// 3x-ui node agent. Windows nodes are probe-only by design: the agent +// reports host facts/metrics and runs latency probes, but no xray or +// sing-box core is ever fetched or started here (the agent core +// machinery stays the master's Linux fleet concern; the local +// agent.json carries "probeOnly": true, written by +// install-agent.ps1). +// +// Usage: +// +// xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console) +// xui-agent-windows.exe install [-config ] register the service +// xui-agent-windows.exe uninstall remove the service +// xui-agent-windows.exe start | stop control the service +// +// The agent lifecycle wiring (config load, baseline derivation, +// logger init, graceful stop) mirrors main.go's agent branch — the +// same internal/agent package does the work either way. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/agent" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/op/go-logging" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + serviceName = "x-ui-agent" + defaultCfgPath = `C:\x-ui\agent.json` +) + +func main() { + log.SetPrefix("[x-ui-agent] ") + args := os.Args[1:] + + // Management subcommands come first; anything else is a run. + if len(args) > 0 { + switch args[0] { + case "install", "uninstall", "start", "stop": + cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:]) + if err := runControl(args[0], cfgPath); err != nil { + log.Fatalf("%s: %v", args[0], err) + } + return + } + } + + cfgPath := parseConfigFlag("xui-agent-windows", args) + + isService, err := svc.IsWindowsService() + if err != nil { + log.Fatalf("detect service environment: %v", err) + } + if isService { + if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil { + log.Fatalf("service run: %v", err) + } + return + } + + // Interactive run: console foreground, Ctrl+C stops gracefully. + running, err := startAgent(cfgPath) + if err != nil { + log.Fatalf("start agent: %v", err) + } + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + select { + case <-sigCh: + log.Println("Shutting down agent...") + running.Stop() + <-running.done + case err := <-running.done: + if err != nil { + log.Fatalf("Agent error: %v", err) + } + } +} + +func parseConfigFlag(name string, args []string) string { + fs := flag.NewFlagSet(name, flag.ExitOnError) + cfg := fs.String("config", defaultCfgPath, "Path to agent config file") + _ = fs.Parse(args) + return *cfg +} + +// runningAgent is one started agent plus its completion signal. +type runningAgent struct { + ag *agent.Agent + done chan error +} + +func (r *runningAgent) Stop() { r.ag.Stop() } + +// startAgent performs the same setup as main.go's agent branch and +// launches the agent loop in the background. +func startAgent(cfgPath string) (*runningAgent, error) { + // Agent mode uses the panel's directory layout for core binaries + // and configs; on a probe-only Windows node nothing consumes it, + // but keep the wiring identical to the Linux entry so behaviour + // cannot drift between the two. + if os.Getenv("XUI_BIN_FOLDER") == "" { + _ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder) + } + + cfg, err := agent.LoadConfig(cfgPath) + if err != nil { + return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err) + } + // The acknowledged traffic baseline persists next to the agent + // config (its state home), unless the config names a path. + if cfg.BaselinePath == "" { + cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json") + } + + logger.InitLogger(logging.INFO) + log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName) + + ag := agent.New(cfg) + r := &runningAgent{ag: ag, done: make(chan error, 1)} + go func() { r.done <- ag.Run() }() + return r, nil +} + +// agentHandler adapts the agent loop to the SCM protocol. +type agentHandler struct { + cfgPath string +} + +func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const accepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + running, err := startAgent(h.cfgPath) + if err != nil { + log.Printf("start agent: %v", err) + return false, 1 + } + status <- svc.Status{State: svc.Running, Accepts: accepted} + for { + select { + case err := <-running.done: + // The agent loop ended on its own (e.g. fatal run error). + if err != nil { + log.Printf("agent exited: %v", err) + return false, 1 + } + return false, 0 + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + status <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + status <- svc.Status{State: svc.StopPending} + running.Stop() + <-running.done + return false, 0 + default: + log.Printf("unexpected service command: %v", c.Cmd) + } + } + } +} + +// runControl implements the install/uninstall/start/stop subcommands +// through the service control manager. +func runControl(cmd, cfgPath string) error { + m, err := mgr.ConnectRemote("") + if err != nil { + return fmt.Errorf("connect to service manager: %w", err) + } + defer m.Disconnect() + + switch cmd { + case "install": + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("locate own binary: %w", err) + } + absCfg, err := filepath.Abs(cfgPath) + if err != nil { + return fmt.Errorf("resolve config path: %w", err) + } + if s, err := m.OpenService(serviceName); err == nil { + s.Close() + return fmt.Errorf("service %s already exists (uninstall first)", serviceName) + } + s, err := m.CreateService(serviceName, exePath, mgr.Config{ + DisplayName: "3x-ui Agent", + Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).", + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + }, "-config", absCfg) + if err != nil { + return fmt.Errorf("create service: %w", err) + } + defer s.Close() + log.Printf("service %s installed (config: %s)", serviceName, absCfg) + return nil + + case "uninstall": + s, err := openService(m) + if err != nil { + return err + } + defer s.Close() + // Stop first so the binary is not locked when the operator + // deletes or replaces it; an already-stopped service is fine. + _, _ = s.Control(svc.Stop) + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + st, err := s.Query() + if err != nil || st.State == svc.Stopped { + break + } + time.Sleep(300 * time.Millisecond) + } + if err := s.Delete(); err != nil { + return fmt.Errorf("delete service: %w", err) + } + log.Printf("service %s removed", serviceName) + return nil + + case "start": + s, err := openService(m) + if err != nil { + return err + } + defer s.Close() + if err := s.Start(); err != nil { + return fmt.Errorf("start service: %w", err) + } + log.Printf("service %s started", serviceName) + return nil + + case "stop": + s, err := openService(m) + if err != nil { + return err + } + defer s.Close() + if _, err := s.Control(svc.Stop); err != nil { + return fmt.Errorf("stop service: %w", err) + } + log.Printf("service %s stop requested", serviceName) + return nil + } + return fmt.Errorf("unknown subcommand %q", cmd) +} + +func openService(m *mgr.Mgr) (*mgr.Service, error) { + s, err := m.OpenService(serviceName) + if err != nil { + return nil, fmt.Errorf("open service %s: %w", serviceName, err) + } + return s, nil +} diff --git a/install-agent.ps1 b/install-agent.ps1 new file mode 100644 index 0000000..8546787 --- /dev/null +++ b/install-agent.ps1 @@ -0,0 +1,102 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Installs the 3x-ui probe-only agent on Windows (64-bit). + +.DESCRIPTION + Downloads x-ui-agent-windows-amd64.exe into C:\x-ui, writes + agent.json with "probeOnly": true (Windows nodes run no proxy + core: facts, metrics and latency probes only), registers the + x-ui-agent Windows service (auto-start) and starts it. + + Re-running is safe: an existing service is stopped and removed + first, and any of -Master/-Token/-Name left empty is inherited + from the agent.json already on disk. + +.EXAMPLE + .\install-agent.ps1 -Master "https://panel.example.com/rogue" -Token "" -Name "win-node-1" +#> +[CmdletBinding()] +param( + [string]$Master, + [string]$Token, + [string]$Name, + [string]$InstallDir = "C:\x-ui", + [string]$AgentUrl = $env:XUI_URL +) + +$ErrorActionPreference = "Stop" + +# Release download base, same shape as install-agent.sh's +# RELEASE_BASE (the version segment is bumped by the release flow). +$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.3" +if (-not $AgentUrl) { + $AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe" +} + +$ServiceName = "x-ui-agent" +$ExeName = "x-ui-agent-windows-amd64.exe" +$ExePath = Join-Path $InstallDir $ExeName +$ConfigPath = Join-Path $InstallDir "agent.json" + +# Inherit missing parameters from an existing agent.json (reruns). +if (Test-Path $ConfigPath) { + $existing = Get-Content $ConfigPath -Raw | ConvertFrom-Json + if (-not $Master) { $Master = $existing.master_url } + if (-not $Token) { $Token = $existing.token } + if (-not $Name) { $Name = $existing.node_name } +} +if (-not $Master) { throw "missing -Master (and no existing agent.json to inherit from)" } +if (-not $Token) { throw "missing -Token (and no existing agent.json to inherit from)" } +if (-not $Name) { throw "missing -Name (and no existing agent.json to inherit from)" } + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + +# Stop and remove any previous install so the exe file is not locked +# by a running service when it is replaced below. Prefer the old +# binary's own subcommands; fall back to sc.exe when it is gone. +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($svc) { + if (Test-Path $ExePath) { + & $ExePath stop | Out-Null + & $ExePath uninstall | Out-Null + } + else { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + & sc.exe delete $ServiceName | Out-Null + } + Start-Sleep -Seconds 1 +} + +# Download the agent binary (via a temp file, then swap into place). +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$tmpExe = "$ExePath.new" +Write-Host "Downloading $AgentUrl" +Invoke-WebRequest -Uri $AgentUrl -OutFile $tmpExe -UseBasicParsing +if (-not (Test-Path $tmpExe) -or (Get-Item $tmpExe).Length -eq 0) { + throw "agent download produced an empty file: $AgentUrl" +} +Move-Item -Force $tmpExe $ExePath + +# Write agent.json. Windows agents are probe-only: probeOnly is +# always set (the design contract, not an option). +$config = [ordered]@{ + master_url = $Master + token = $Token + node_name = $Name + probeOnly = $true +} +$json = $config | ConvertTo-Json +# Go's encoding/json rejects a UTF-8 BOM, which Set-Content -Encoding +# UTF8 would add on Windows PowerShell 5.1: write BOM-less UTF-8. +[System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false))) +Write-Host "Wrote $ConfigPath (probeOnly)" + +# Register and start the service. +& $ExePath install -config $ConfigPath +if ($LASTEXITCODE -ne 0) { throw "service install failed (exit $LASTEXITCODE)" } +& $ExePath start +if ($LASTEXITCODE -ne 0) { throw "service start failed (exit $LASTEXITCODE)" } + +Write-Host "3x-ui agent installed and running as service '$ServiceName'." +Write-Host "Update later by re-running this script; the master's one-click agent update is intentionally not consumed on Windows." diff --git a/internal/agent/probe.go b/internal/agent/probe.go index e4179de..ae280d1 100644 --- a/internal/agent/probe.go +++ b/internal/agent/probe.go @@ -1,34 +1,34 @@ package agent -// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go: -// host Facts (once per process) and per-heartbeat Metrics read directly from -// /proc and /sys with the standard library only. The port keeps the source's +// 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: MemTotal - MemAvailable; -// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree); -// - network totals sum only "counted" interfaces — loopback, container, -// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer -// filter so no byte is counted twice; -// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set, -// so a set change re-baselines exactly like a reboot; -// - every source degrades independently: an unreadable file yields a zero -// field, never a failed collection round. +// - 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" - "os" - "path/filepath" - "runtime" "sort" - "strconv" "strings" "sync" "sync/atomic" - "syscall" "time" - - "github.com/mhsanaei/3x-ui/v3/internal/config" ) // ProbeFacts mirrors monitor's Facts frame: static host identity, sampled @@ -72,7 +72,7 @@ type ProbeMetrics struct { } // probeCollector holds the cross-sample state the rates need (previous CPU -// jiffies and per-interface counters). Kept in memory only, like the source. +// counters and per-interface counters). Kept in memory only, like the source. type probeCollector struct { mu sync.Mutex prevCPU *cpuSample @@ -80,6 +80,9 @@ type probeCollector struct { 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 ( @@ -94,37 +97,15 @@ func collectFacts() *ProbeFacts { return factsCache } -func sampleFacts() *ProbeFacts { - mem := parseMeminfo(readProcFile("meminfo")) - diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts"))) - cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo")) - v4, v6 := probeAddresses() - return &ProbeFacts{ - Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"), - OS: osPrettyName(), - Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"), - Arch: runtime.GOARCH, - Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")), - CPUName: cpuName, - CPUCores: cpuCores, - MemTotal: mem["MemTotal"], - SwapTotal: mem["SwapTotal"], - DiskTotal: diskTotal, - AgentVersion: config.GetAgentDisplayVersion(), - IPv4: v4, - IPv6: v6, - } -} - // 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 below. The pin bypasses the three-layer 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 /proc/net/dev counts nothing — -// the boot epoch changes with the counted set, so the master -// re-baselines instead of booking a phantom delta. +// 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. @@ -137,6 +118,12 @@ func statIfacePinValue() 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 { @@ -146,151 +133,9 @@ func pinnedInterfaces(all []netCounters, pin string) []netCounters { return nil } -// collectMetrics takes one full sample. Individual sources fail soft. -func collectMetrics() *ProbeMetrics { - probeColl.mu.Lock() - defer probeColl.mu.Unlock() - mem := parseMeminfo(readProcFile("meminfo")) - memTotal, memUsed := memUsedFree(mem) - swapTotal, swapUsed := swapUsedFree(mem) - diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts"))) - allIfaces := parseNetDev(readProcFile("net/dev")) - counted := countedInterfaces(allIfaces, sysNetRoot) - if pin := statIfacePinValue(); pin != "" { - counted = pinnedInterfaces(allIfaces, pin) - } - var rxTotal, txTotal uint64 - names := make([]string, 0, len(counted)) - for _, c := range counted { - rxTotal += c.rx - txTotal += c.tx - names = append(names, c.name) - } - bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names) - rxRate, txRate := probeColl.netRate(counted, time.Now()) - tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6")) - return &ProbeMetrics{ - BootID: bootID, - Uptime: parseUptime(readProcFile("uptime")), - CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))), - Load: parseLoadavg(readProcFile("loadavg")), - MemTotal: memTotal, - MemUsed: memUsed, - SwapTotal: swapTotal, - SwapUsed: swapUsed, - DiskTotal: diskTotal, - DiskUsed: diskUsed, - NetRxTotal: rxTotal, - NetTxTotal: txTotal, - NetRx: rxRate, - NetTx: txRate, - TCP: tcp, - UDP: udp, - Procs: procCount(), - } -} - -// --- file helpers ----------------------------------------------------------- - -const sysNetRoot = "/sys/class/net" - -func readProcFile(name string) string { - b, err := os.ReadFile(filepath.Join("/proc", name)) - if err != nil { - return "" - } - return string(b) -} - -func readTrimFile(path, fallback string) string { - b, err := os.ReadFile(path) - if err != nil { - return fallback - } - if s := strings.TrimSpace(string(b)); s != "" { - return s - } - return fallback -} - -// --- memory (free口径) ------------------------------------------------------- - -func parseMeminfo(text string) map[string]uint64 { - out := make(map[string]uint64) - for _, line := range strings.Split(text, "\n") { - key, rest, ok := strings.Cut(line, ":") - if !ok { - continue - } - f := strings.Fields(rest) - if len(f) == 0 { - continue - } - kb, err := strconv.ParseUint(f[0], 10, 64) - if err != nil { - continue - } - out[key] = kb * 1024 - } - return out -} - -// memUsedFree is free(1)'s used column: total - MemAvailable, with the -// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an -// available above the total, as LXCFS can report, -> total - MemFree). -func memUsedFree(m map[string]uint64) (total, used uint64) { - total = m["MemTotal"] - if total == 0 { - return 0, 0 - } - avail, ok := m["MemAvailable"] - if !ok { - avail = m["MemFree"] + m["Buffers"] + m["Cached"] - } - if avail > total { - avail = m["MemFree"] - } - if avail > total { - avail = total - } - return total, total - avail -} - -func swapUsedFree(m map[string]uint64) (total, used uint64) { - total = m["SwapTotal"] - free := m["SwapFree"] - if free > total { - free = total - } - return total, total - free -} - -// --- cpu -------------------------------------------------------------------- - -func parseCPUJiffies(stat string) *cpuSample { - for _, line := range strings.Split(stat, "\n") { - if !strings.HasPrefix(line, "cpu ") { - continue - } - f := strings.Fields(line[4:]) - if len(f) < 5 { - return nil - } - var v [8]uint64 - var total uint64 - for i := 0; i < len(f) && i < 8; i++ { - n, err := strconv.ParseUint(f[i], 10, 64) - if err != nil { - return nil - } - v[i] = n - total += n - } - return &cpuSample{total: total, idle: v[3] + v[4]} - } - 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 @@ -315,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 { return pct } -func parseLoadavg(text string) [3]float32 { - var out [3]float32 - f := strings.Fields(text) - for i := 0; i < 3 && i < len(f); i++ { - if v, err := strconv.ParseFloat(f[i], 32); err == nil { - out[i] = float32(v) - } - } - return out -} - -func parseUptime(text string) uint64 { - f := strings.Fields(text) - if len(f) == 0 { - return 0 - } - v, err := strconv.ParseFloat(f[0], 64) - if err != nil || v < 0 { - return 0 - } - return uint64(v) -} - -// cpuInfo returns the display name and logical core count from /proc/cpuinfo. -func cpuInfo(text string) (string, uint32) { - name := "" - cores := uint32(0) - for _, line := range strings.Split(text, "\n") { - key, val, ok := strings.Cut(line, ":") - if !ok { - continue - } - switch strings.TrimSpace(key) { - case "processor": - cores++ - case "model name", "Model", "cpu model": - if name == "" { - name = strings.TrimSpace(val) - } - } - } - if cores == 0 { - cores = uint32(runtime.NumCPU()) - if cores == 0 { - cores = 1 - } - } - if name == "" { - name = armCPUName(text) - } - if name == "" { - name = "unknown" - } - return name, cores -} - -// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does, -// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76). -func armCPUName(text string) string { - var vendor *armVendor - var names []string - seen := map[string]bool{} - for _, line := range strings.Split(text, "\n") { - key, val, ok := strings.Cut(line, ":") - if !ok { - continue - } - switch strings.TrimSpace(key) { - case "CPU implementer": - vendor = nil - if id, ok := parseHexID(val); ok { - for i := range armVendors { - if armVendors[i].id == id { - vendor = &armVendors[i] - } - } - } - case "CPU part": - if vendor == nil { - continue - } - name := vendor.name - if id, ok := parseHexID(val); ok { - for _, p := range vendor.parts { - if p.id == id { - name = p.name - } - } - } - if !seen[name] { - seen[name] = true - names = append(names, name) - } - } - } - return strings.Join(names, "/") -} - -func parseHexID(s string) (uint32, bool) { - s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x")) - v, err := strconv.ParseUint(s, 16, 32) - if err != nil { - return 0, false - } - return uint32(v), true -} - -type armPart struct { - id uint32 - name string -} -type armVendor struct { - id uint32 - name string - parts []armPart -} - -// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts -// plausibly met on VPS hardware. An unlisted part falls back to the vendor. -var armVendors = []armVendor{ - {0x41, "ARM", []armPart{ - {0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"}, - {0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"}, - {0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"}, - {0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"}, - {0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"}, - {0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"}, - {0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"}, - {0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"}, - {0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"}, - }}, - {0x48, "HiSilicon", []armPart{ - {0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"}, - {0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"}, - }}, - {0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}}, - {0x50, "APM", []armPart{{0x000, "X-Gene"}}}, - {0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}}, - {0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}}, -} - -// --- os / virt --------------------------------------------------------------- - -func osPrettyName() string { - for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} { - b, err := os.ReadFile(path) - if err != nil { - continue - } - for _, line := range strings.Split(string(b), "\n") { - if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok { - return strings.Trim(v, "\"'") - } - } - } - return "Linux" -} - -// detectVirt ports the source's chain: containers first, then hypervisor. -// CPUID is unreachable from Go's stdlib, so the x86 signature check is -// replaced by the DMI strings (the source's own non-x86 fallback) plus the -// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both -// reads as "none", same caveat the source documents for cleared CPUID bits. -func detectVirt(mounts, cpuinfo string) string { - if _, err := os.Stat("/proc/vz"); err == nil { - if _, err := os.Stat("/proc/bc"); err != nil { - return "openvz" - } - } - if s := readTrimFile("/run/systemd/container", ""); s != "" { - return s - } - if _, err := os.Stat("/.dockerenv"); err == nil { - return "docker" - } - if _, err := os.Stat("/run/.containerenv"); err == nil { - return "podman" - } - for _, line := range strings.Split(mounts, "\n") { - f := strings.Fields(line) - if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") { - return "lxc" - } - } - if _, err := os.Stat("/proc/xen"); err == nil { - return "xen" - } - if _, err := os.Stat("/sys/hypervisor/type"); err == nil { - return "xen" - } - dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " + - readTrimFile("/sys/class/dmi/id/sys_vendor", "")) - for _, m := range []struct{ word, name string }{ - {"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"}, - {"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"}, - {"bhyve", "bhyve"}, {"xen", "xen"}, - } { - if strings.Contains(dmi, m.word) { - return m.name - } - } - if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil { - return "kvm" - } - if strings.Contains(cpuinfo, "hypervisor") { - return "vm" - } - return "none" -} - -// --- network: counted set, epoch, rates ------------------------------------- - -type netCounters struct { - name string - rx, tx uint64 -} - -func parseNetDev(text string) []netCounters { - var out []netCounters - lines := strings.Split(text, "\n") - for _, line := range lines { - name, rest, ok := strings.Cut(line, ":") - if !ok { - continue - } - f := strings.Fields(rest) - if len(f) < 16 { - continue - } - rx, err1 := strconv.ParseUint(f[0], 10, 64) - tx, err2 := strconv.ParseUint(f[8], 10, 64) - if err1 != nil || err2 != nil { - continue - } - out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx}) - } - return out -} - -// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES). -var skipIfacePrefixes = []string{ - "lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale", - "cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap", - "erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt", -} - -func skipIfaceName(name string) bool { - for _, p := range skipIfacePrefixes { - if strings.HasPrefix(name, p) { - return true - } - } - return false -} - -// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays — -// the kernel books their bytes again on the device beneath. -func isStackedName(name string) bool { - if strings.Contains(name, ".") { - return true - } - for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} { - if strings.HasPrefix(name, p) { - return true - } - } - return false -} - -var tunnelLinkTypes = map[string]bool{ - "65534": true, "768": true, "769": true, "776": true, "778": true, "823": true, -} - -// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes -// are already counted on another device. Hardware (a `device` link) exempts -// a device from the tunnel/port rules — a raw-IP LTE modem shares its link -// type with WireGuard but carries real traffic. -func countedElsewhere(sysRoot, name string) bool { - dev := filepath.Join(sysRoot, name) - uevent := readTrimFile(filepath.Join(dev, "uevent"), "") - devtypeIs := func(set ...string) bool { - for _, line := range strings.Split(uevent, "\n") { - for _, want := range set { - if line == want { - return true - } - } - } - return false - } - if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") { - return true - } - if entries, err := os.ReadDir(dev); err == nil { - for _, e := range entries { - if strings.HasPrefix(e.Name(), "lower_") { - return true - } - } - } - if _, err := os.Stat(filepath.Join(dev, "device")); err == nil { - return false - } - if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil { - return true - } - if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] { - return true - } - return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve") -} - -// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs. -func ifaceCounts(sysRoot, name string) bool { - if skipIfaceName(name) || isStackedName(name) { - return false - } - return !countedElsewhere(sysRoot, name) -} - -func countedInterfaces(all []netCounters, sysRoot string) []netCounters { - out := make([]netCounters, 0, len(all)) - for _, c := range all { - if ifaceCounts(sysRoot, c.name) { - out = append(out, c) - } - } - return out -} - -// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}". +// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}". // The master only tests it for equality: any change re-baselines. -func bootEpoch(kernelBootID string, names []string) string { +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 kernelBootID + "/" + hex16(h.Sum64()) + return bootID + "/" + hex16(h.Sum64()) } func hex16(v uint64) string { @@ -697,136 +212,6 @@ func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64, return uint64(float64(rx) / secs), uint64(float64(tx) / secs) } -// --- addresses (scope §1.5) --------------------------------------------------- - -// probeAddresses picks one address per family: candidates come from -// non-skip interfaces that are up, are not link-local, prefer public over -// private, and for v6 avoid temporary/deprecated/tentative addresses. -func probeAddresses() (string, string) { - transient := transientV6(readProcFile("net/if_inet6")) - ifaces, err := net.Interfaces() - if err != nil { - return "", "" - } - type cand struct { - ip net.IP - transient bool - } - var held []cand - for _, ifc := range ifaces { - if ifc.Flags&net.FlagUp == 0 { - continue - } - if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" { - 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 || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - continue - } - if !lendsAddress(ifc.Name, ip) { - continue - } - c := cand{ip: ip} - if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil { - for _, t := range transient { - if t.Equal(ip6) { - c.transient = true - } - } - } - held = append(held, c) - } - } - pick := func(v6 bool) string { - var best *cand - for i := range held { - c := &held[i] - if (c.ip.To4() == nil) != v6 { - continue - } - if best == nil { - best = c - continue - } - bp, cp := isPublicIP(best.ip), isPublicIP(c.ip) - if cp != bp { - if cp { - best = c - } - continue - } - if v6 && best.transient && !c.transient { - best = c - } - } - if best == nil { - return "" - } - return best.ip.String() - } - return pick(false), pick(true) -} - -// lendsAddress mirrors the source: a skipped interface lends no address, -// except a WireGuard interface lending a public v6 that is not in -// Cloudflare WARP's shared 2606:4700::/32. -func lendsAddress(name string, ip net.IP) bool { - if !skipIfaceName(name) { - return true - } - if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) { - ip16 := ip.To16() - return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00) - } - return false -} - -// transientV6 parses /proc/net/if_inet6 for addresses carrying -// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags. -func transientV6(text string) []net.IP { - const transientFlags = 0x01 | 0x08 | 0x20 | 0x40 - var out []net.IP - for _, line := range strings.Split(text, "\n") { - f := strings.Fields(line) - if len(f) < 6 { - continue - } - flags, err := strconv.ParseUint(f[4], 16, 32) - if err != nil || flags&transientFlags == 0 { - continue - } - if len(f[0]) != 32 { - continue - } - ip := make(net.IP, 16) - ok := true - for i := 0; i < 16; i++ { - b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8) - if err != nil { - ok = false - break - } - ip[i] = byte(b) - } - if ok { - out = append(out, ip) - } - } - return out -} - // 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. @@ -853,133 +238,3 @@ func isPublicIP(ip net.IP) bool { } return ip16[0]&0xe0 == 0x20 } - -// --- sockets / processes ------------------------------------------------------ - -func connCounts(v4, v6 string) (tcp, udp uint32) { - stat := func(text, prefix, key string) uint32 { - for _, line := range strings.Split(text, "\n") { - if !strings.HasPrefix(line, prefix) { - continue - } - f := strings.Fields(line[len(prefix):]) - for i := 0; i+1 < len(f); i++ { - if f[i] == key { - if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil { - return uint32(n) - } - return 0 - } - } - } - return 0 - } - tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse") - udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse") - return tcp, udp -} - -func procCount() uint32 { - entries, err := os.ReadDir("/proc") - if err != nil { - return 0 - } - var n uint32 - for _, e := range entries { - name := e.Name() - digits := name != "" - for i := 0; i < len(name); i++ { - if name[i] < '0' || name[i] > '9' { - digits = false - break - } - } - if digits { - n++ - } - } - return n -} - -// --- disk (df口径) --------------------------------------------------------------- - -var skipFSTypes = map[string]bool{ - "tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true, - "cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true, - "hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true, - "pstore": true, "bpf": true, "configfs": true, "fusectl": true, - "binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true, - "efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true, - "fuse": true, "rpc_pipefs": true, - "nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true, - "glusterfs": true, "9p": true, -} - -func skipFSType(fstype string) bool { - if skipFSTypes[fstype] { - return true - } - // Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source. - if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] { - return true - } - return false -} - -// realMountPoints ports parse_mounts: only the topmost mount per path, -// deduped by source device (zfs by pool), real storage only. -func realMountPoints(text string) []string { - type row struct{ dev, mount, fstype string } - var rows []row - top := map[string]int{} - for _, line := range strings.Split(text, "\n") { - f := strings.Fields(line) - if len(f) < 3 { - continue - } - rows = append(rows, row{f[0], f[1], f[2]}) - top[f[1]] = len(rows) - 1 - } - seen := map[string]bool{} - var out []string - for i, r := range rows { - if top[r.mount] != i || skipFSType(r.fstype) { - continue - } - if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" { - continue - } - key := r.dev - if r.fstype == "zfs" { - if j := strings.IndexByte(key, '/'); j >= 0 { - key = key[:j] - } - } - if seen[key] { - continue - } - seen[key] = true - out = append(out, strings.ReplaceAll(r.mount, `\040`, " ")) - } - return out -} - -// diskUsage sums statfs over the mounts: total = f_blocks, used = -// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve). -func diskUsage(mounts []string) (total, used uint64) { - for _, m := range mounts { - var st syscall.Statfs_t - if err := syscall.Statfs(m, &st); err != nil { - continue - } - bs := uint64(st.Frsize) - if bs == 0 { - bs = uint64(st.Bsize) - } - total += st.Blocks * bs - if st.Blocks > st.Bfree { - used += (st.Blocks - st.Bfree) * bs - } - } - return total, used -} diff --git a/internal/agent/probe_linux.go b/internal/agent/probe_linux.go new file mode 100644 index 0000000..3df238c --- /dev/null +++ b/internal/agent/probe_linux.go @@ -0,0 +1,778 @@ +//go:build linux + +package agent + +// probe_linux.go is the Linux sampler for the probe collector: host +// Facts and per-heartbeat Metrics read directly from /proc and /sys +// with the standard library only. The accounting discipline (see +// probe.go) is the source's: +// - memory "used" is free(1)'s column: MemTotal - MemAvailable; +// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree); +// - network totals sum only "counted" interfaces, selected by the +// three-layer filter below so no byte is counted twice; +// - boot epoch = kernel boot_id + fingerprint of the counted set. + +import ( + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" +) + +func sampleFacts() *ProbeFacts { + mem := parseMeminfo(readProcFile("meminfo")) + diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts"))) + cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo")) + v4, v6 := probeAddresses() + return &ProbeFacts{ + Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"), + OS: osPrettyName(), + Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"), + Arch: runtime.GOARCH, + Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")), + CPUName: cpuName, + CPUCores: cpuCores, + MemTotal: mem["MemTotal"], + SwapTotal: mem["SwapTotal"], + DiskTotal: diskTotal, + AgentVersion: config.GetAgentDisplayVersion(), + IPv4: v4, + IPv6: v6, + } +} + +// collectMetrics takes one full sample. Individual sources fail soft. +func collectMetrics() *ProbeMetrics { + probeColl.mu.Lock() + defer probeColl.mu.Unlock() + mem := parseMeminfo(readProcFile("meminfo")) + memTotal, memUsed := memUsedFree(mem) + swapTotal, swapUsed := swapUsedFree(mem) + diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts"))) + allIfaces := parseNetDev(readProcFile("net/dev")) + counted := countedInterfaces(allIfaces, sysNetRoot) + if pin := statIfacePinValue(); pin != "" { + counted = pinnedInterfaces(allIfaces, pin) + } + var rxTotal, txTotal uint64 + names := make([]string, 0, len(counted)) + for _, c := range counted { + rxTotal += c.rx + txTotal += c.tx + names = append(names, c.name) + } + bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names) + rxRate, txRate := probeColl.netRate(counted, time.Now()) + tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6")) + return &ProbeMetrics{ + BootID: bootID, + Uptime: parseUptime(readProcFile("uptime")), + CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))), + Load: parseLoadavg(readProcFile("loadavg")), + MemTotal: memTotal, + MemUsed: memUsed, + SwapTotal: swapTotal, + SwapUsed: swapUsed, + DiskTotal: diskTotal, + DiskUsed: diskUsed, + NetRxTotal: rxTotal, + NetTxTotal: txTotal, + NetRx: rxRate, + NetTx: txRate, + TCP: tcp, + UDP: udp, + Procs: procCount(), + } +} + +// --- file helpers ----------------------------------------------------------- + +const sysNetRoot = "/sys/class/net" + +func readProcFile(name string) string { + b, err := os.ReadFile(filepath.Join("/proc", name)) + if err != nil { + return "" + } + return string(b) +} + +func readTrimFile(path, fallback string) string { + b, err := os.ReadFile(path) + if err != nil { + return fallback + } + if s := strings.TrimSpace(string(b)); s != "" { + return s + } + return fallback +} + +// --- memory (free口径) ------------------------------------------------------- + +func parseMeminfo(text string) map[string]uint64 { + out := make(map[string]uint64) + for _, line := range strings.Split(text, "\n") { + key, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + f := strings.Fields(rest) + if len(f) == 0 { + continue + } + kb, err := strconv.ParseUint(f[0], 10, 64) + if err != nil { + continue + } + out[key] = kb * 1024 + } + return out +} + +// memUsedFree is free(1)'s used column: total - MemAvailable, with the +// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an +// available above the total, as LXCFS can report, -> total - MemFree). +func memUsedFree(m map[string]uint64) (total, used uint64) { + total = m["MemTotal"] + if total == 0 { + return 0, 0 + } + avail, ok := m["MemAvailable"] + if !ok { + avail = m["MemFree"] + m["Buffers"] + m["Cached"] + } + if avail > total { + avail = m["MemFree"] + } + if avail > total { + avail = total + } + return total, total - avail +} + +func swapUsedFree(m map[string]uint64) (total, used uint64) { + total = m["SwapTotal"] + free := m["SwapFree"] + if free > total { + free = total + } + return total, total - free +} + +// --- cpu -------------------------------------------------------------------- + +func parseCPUJiffies(stat string) *cpuSample { + for _, line := range strings.Split(stat, "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + f := strings.Fields(line[4:]) + if len(f) < 5 { + return nil + } + var v [8]uint64 + var total uint64 + for i := 0; i < len(f) && i < 8; i++ { + n, err := strconv.ParseUint(f[i], 10, 64) + if err != nil { + return nil + } + v[i] = n + total += n + } + return &cpuSample{total: total, idle: v[3] + v[4]} + } + return nil +} + +func parseLoadavg(text string) [3]float32 { + var out [3]float32 + f := strings.Fields(text) + for i := 0; i < 3 && i < len(f); i++ { + if v, err := strconv.ParseFloat(f[i], 32); err == nil { + out[i] = float32(v) + } + } + return out +} + +func parseUptime(text string) uint64 { + f := strings.Fields(text) + if len(f) == 0 { + return 0 + } + v, err := strconv.ParseFloat(f[0], 64) + if err != nil || v < 0 { + return 0 + } + return uint64(v) +} + +// cpuInfo returns the display name and logical core count from /proc/cpuinfo. +func cpuInfo(text string) (string, uint32) { + name := "" + cores := uint32(0) + for _, line := range strings.Split(text, "\n") { + key, val, ok := strings.Cut(line, ":") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "processor": + cores++ + case "model name", "Model", "cpu model": + if name == "" { + name = strings.TrimSpace(val) + } + } + } + if cores == 0 { + cores = uint32(runtime.NumCPU()) + if cores == 0 { + cores = 1 + } + } + if name == "" { + name = armCPUName(text) + } + if name == "" { + name = "unknown" + } + return name, cores +} + +// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does, +// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76). +func armCPUName(text string) string { + var vendor *armVendor + var names []string + seen := map[string]bool{} + for _, line := range strings.Split(text, "\n") { + key, val, ok := strings.Cut(line, ":") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "CPU implementer": + vendor = nil + if id, ok := parseHexID(val); ok { + for i := range armVendors { + if armVendors[i].id == id { + vendor = &armVendors[i] + } + } + } + case "CPU part": + if vendor == nil { + continue + } + name := vendor.name + if id, ok := parseHexID(val); ok { + for _, p := range vendor.parts { + if p.id == id { + name = p.name + } + } + } + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } + return strings.Join(names, "/") +} + +func parseHexID(s string) (uint32, bool) { + s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x")) + v, err := strconv.ParseUint(s, 16, 32) + if err != nil { + return 0, false + } + return uint32(v), true +} + +type armPart struct { + id uint32 + name string +} +type armVendor struct { + id uint32 + name string + parts []armPart +} + +// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts +// plausibly met on VPS hardware. An unlisted part falls back to the vendor. +var armVendors = []armVendor{ + {0x41, "ARM", []armPart{ + {0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"}, + {0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"}, + {0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"}, + {0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"}, + {0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"}, + {0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"}, + {0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"}, + {0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"}, + {0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"}, + }}, + {0x48, "HiSilicon", []armPart{ + {0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"}, + {0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"}, + }}, + {0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}}, + {0x50, "APM", []armPart{{0x000, "X-Gene"}}}, + {0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}}, + {0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}}, +} + +// --- os / virt --------------------------------------------------------------- + +func osPrettyName() string { + for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} { + b, err := os.ReadFile(path) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok { + return strings.Trim(v, "\"'") + } + } + } + return "Linux" +} + +// detectVirt ports the source's chain: containers first, then hypervisor. +// CPUID is unreachable from Go's stdlib, so the x86 signature check is +// replaced by the DMI strings (the source's own non-x86 fallback) plus the +// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both +// reads as "none", same caveat the source documents for cleared CPUID bits. +func detectVirt(mounts, cpuinfo string) string { + if _, err := os.Stat("/proc/vz"); err == nil { + if _, err := os.Stat("/proc/bc"); err != nil { + return "openvz" + } + } + if s := readTrimFile("/run/systemd/container", ""); s != "" { + return s + } + if _, err := os.Stat("/.dockerenv"); err == nil { + return "docker" + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + return "podman" + } + for _, line := range strings.Split(mounts, "\n") { + f := strings.Fields(line) + if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") { + return "lxc" + } + } + if _, err := os.Stat("/proc/xen"); err == nil { + return "xen" + } + if _, err := os.Stat("/sys/hypervisor/type"); err == nil { + return "xen" + } + dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " + + readTrimFile("/sys/class/dmi/id/sys_vendor", "")) + for _, m := range []struct{ word, name string }{ + {"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"}, + {"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"}, + {"bhyve", "bhyve"}, {"xen", "xen"}, + } { + if strings.Contains(dmi, m.word) { + return m.name + } + } + if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil { + return "kvm" + } + if strings.Contains(cpuinfo, "hypervisor") { + return "vm" + } + return "none" +} + +// --- network: counted set, epoch, rates ------------------------------------- + +func parseNetDev(text string) []netCounters { + var out []netCounters + lines := strings.Split(text, "\n") + for _, line := range lines { + name, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + f := strings.Fields(rest) + if len(f) < 16 { + continue + } + rx, err1 := strconv.ParseUint(f[0], 10, 64) + tx, err2 := strconv.ParseUint(f[8], 10, 64) + if err1 != nil || err2 != nil { + continue + } + out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx}) + } + return out +} + +// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES). +var skipIfacePrefixes = []string{ + "lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale", + "cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap", + "erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt", +} + +func skipIfaceName(name string) bool { + for _, p := range skipIfacePrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays — +// the kernel books their bytes again on the device beneath. +func isStackedName(name string) bool { + if strings.Contains(name, ".") { + return true + } + for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +var tunnelLinkTypes = map[string]bool{ + "65534": true, "768": true, "769": true, "776": true, "778": true, "823": true, +} + +// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes +// are already counted on another device. Hardware (a `device` link) exempts +// a device from the tunnel/port rules — a raw-IP LTE modem shares its link +// type with WireGuard but carries real traffic. +func countedElsewhere(sysRoot, name string) bool { + dev := filepath.Join(sysRoot, name) + uevent := readTrimFile(filepath.Join(dev, "uevent"), "") + devtypeIs := func(set ...string) bool { + for _, line := range strings.Split(uevent, "\n") { + for _, want := range set { + if line == want { + return true + } + } + } + return false + } + if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") { + return true + } + if entries, err := os.ReadDir(dev); err == nil { + for _, e := range entries { + if strings.HasPrefix(e.Name(), "lower_") { + return true + } + } + } + if _, err := os.Stat(filepath.Join(dev, "device")); err == nil { + return false + } + if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil { + return true + } + if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] { + return true + } + return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve") +} + +// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs. +func ifaceCounts(sysRoot, name string) bool { + if skipIfaceName(name) || isStackedName(name) { + return false + } + return !countedElsewhere(sysRoot, name) +} + +func countedInterfaces(all []netCounters, sysRoot string) []netCounters { + out := make([]netCounters, 0, len(all)) + for _, c := range all { + if ifaceCounts(sysRoot, c.name) { + out = append(out, c) + } + } + return out +} + +// --- addresses (scope §1.5) --------------------------------------------------- + +// probeAddresses picks one address per family: candidates come from +// non-skip interfaces that are up, are not link-local, prefer public over +// private, and for v6 avoid temporary/deprecated/tentative addresses. +func probeAddresses() (string, string) { + transient := transientV6(readProcFile("net/if_inet6")) + ifaces, err := net.Interfaces() + if err != nil { + return "", "" + } + type cand struct { + ip net.IP + transient bool + } + var held []cand + for _, ifc := range ifaces { + if ifc.Flags&net.FlagUp == 0 { + continue + } + if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" { + 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 || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + continue + } + if !lendsAddress(ifc.Name, ip) { + continue + } + c := cand{ip: ip} + if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil { + for _, t := range transient { + if t.Equal(ip6) { + c.transient = true + } + } + } + held = append(held, c) + } + } + pick := func(v6 bool) string { + var best *cand + for i := range held { + c := &held[i] + if (c.ip.To4() == nil) != v6 { + continue + } + if best == nil { + best = c + continue + } + bp, cp := isPublicIP(best.ip), isPublicIP(c.ip) + if cp != bp { + if cp { + best = c + } + continue + } + if v6 && best.transient && !c.transient { + best = c + } + } + if best == nil { + return "" + } + return best.ip.String() + } + return pick(false), pick(true) +} + +// lendsAddress mirrors the source: a skipped interface lends no address, +// except a WireGuard interface lending a public v6 that is not in +// Cloudflare WARP's shared 2606:4700::/32. +func lendsAddress(name string, ip net.IP) bool { + if !skipIfaceName(name) { + return true + } + if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) { + ip16 := ip.To16() + return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00) + } + return false +} + +// transientV6 parses /proc/net/if_inet6 for addresses carrying +// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags. +func transientV6(text string) []net.IP { + const transientFlags = 0x01 | 0x08 | 0x20 | 0x40 + var out []net.IP + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) < 6 { + continue + } + flags, err := strconv.ParseUint(f[4], 16, 32) + if err != nil || flags&transientFlags == 0 { + continue + } + if len(f[0]) != 32 { + continue + } + ip := make(net.IP, 16) + ok := true + for i := 0; i < 16; i++ { + b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8) + if err != nil { + ok = false + break + } + ip[i] = byte(b) + } + if ok { + out = append(out, ip) + } + } + return out +} + +// --- sockets / processes ------------------------------------------------------ + +func connCounts(v4, v6 string) (tcp, udp uint32) { + stat := func(text, prefix, key string) uint32 { + for _, line := range strings.Split(text, "\n") { + if !strings.HasPrefix(line, prefix) { + continue + } + f := strings.Fields(line[len(prefix):]) + for i := 0; i+1 < len(f); i++ { + if f[i] == key { + if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil { + return uint32(n) + } + return 0 + } + } + } + return 0 + } + tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse") + udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse") + return tcp, udp +} + +func procCount() uint32 { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + var n uint32 + for _, e := range entries { + name := e.Name() + digits := name != "" + for i := 0; i < len(name); i++ { + if name[i] < '0' || name[i] > '9' { + digits = false + break + } + } + if digits { + n++ + } + } + return n +} + +// --- disk (df口径) --------------------------------------------------------------- + +var skipFSTypes = map[string]bool{ + "tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true, + "cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true, + "hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true, + "pstore": true, "bpf": true, "configfs": true, "fusectl": true, + "binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true, + "efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true, + "fuse": true, "rpc_pipefs": true, + "nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true, + "glusterfs": true, "9p": true, +} + +func skipFSType(fstype string) bool { + if skipFSTypes[fstype] { + return true + } + // Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source. + if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] { + return true + } + return false +} + +// realMountPoints ports parse_mounts: only the topmost mount per path, +// deduped by source device (zfs by pool), real storage only. +func realMountPoints(text string) []string { + type row struct{ dev, mount, fstype string } + var rows []row + top := map[string]int{} + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) < 3 { + continue + } + rows = append(rows, row{f[0], f[1], f[2]}) + top[f[1]] = len(rows) - 1 + } + seen := map[string]bool{} + var out []string + for i, r := range rows { + if top[r.mount] != i || skipFSType(r.fstype) { + continue + } + if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" { + continue + } + key := r.dev + if r.fstype == "zfs" { + if j := strings.IndexByte(key, '/'); j >= 0 { + key = key[:j] + } + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, strings.ReplaceAll(r.mount, `\040`, " ")) + } + return out +} + +// diskUsage sums statfs over the mounts: total = f_blocks, used = +// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve). +func diskUsage(mounts []string) (total, used uint64) { + for _, m := range mounts { + var st syscall.Statfs_t + if err := syscall.Statfs(m, &st); err != nil { + continue + } + bs := uint64(st.Frsize) + if bs == 0 { + bs = uint64(st.Bsize) + } + total += st.Blocks * bs + if st.Blocks > st.Bfree { + used += (st.Blocks - st.Bfree) * bs + } + } + return total, used +} diff --git a/internal/agent/probe_test.go b/internal/agent/probe_test.go index b4f5a4e..b16f0c4 100644 --- a/internal/agent/probe_test.go +++ b/internal/agent/probe_test.go @@ -1,3 +1,9 @@ +//go:build linux + +// This file tests the Linux sampler's /proc and /sys parsers +// (probe_linux.go); the shared machinery has its own coverage and the +// Windows sampler's pure helpers live in probe_windows_test.go. + package agent import ( diff --git a/internal/agent/probe_windows.go b/internal/agent/probe_windows.go new file mode 100644 index 0000000..041b3ea --- /dev/null +++ b/internal/agent/probe_windows.go @@ -0,0 +1,463 @@ +//go:build windows + +package agent + +// probe_windows.go is the Windows sampler for the probe collector, +// feeding the same Facts/Metrics contract and accounting discipline +// as the Linux sampler (see probe.go) from the Win32 surface: +// +// - CPU%: GetSystemTimes differenced through the shared cpuPercent +// (kernel time includes idle, exactly like /proc/stat jiffies); +// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys +// (the free(1) "available" column's spirit); swap is the pagefile +// pair (TotalPageFile/AvailPageFile); +// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed +// like df: used = total - free bytes (GetDiskFreeSpaceEx's +// TotalFree, NOT the caller-available figure, which discounts +// quotas the way f_bavail discounts the ext reserve); +// - network: GetIfTable2 rows filtered to OperStatus Up, ifType +// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates +// the Linux three-layer filter: Windows exposes no stacking +// topology in the MIB, but tunnel/virtual adapters report their +// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of +// physical media lands on the same counted set in practice. The +// cost of the difference: a physical-medium bond/virtual switch +// vNIC presenting as Ethernet IS counted here even where Linux +// layer 3 would have skipped it; +// - boot identity: the registry MachineGuid stands in for the +// kernel boot_id; the counted-set FNV fingerprint is shared, so a +// NIC change re-baselines exactly like on Linux; +// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper); +// - load averages, TCP/UDP connection counts have no exported +// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw +// counters exist for CPU/memory, but the package exports no +// GetTcpTable2/GetUdpTable2 wrapper, so per the independent- +// degradation discipline Load stays zero and TCP/UDP stay 0 +// until a wrapper exists; +// - every call fails soft to zeros, like an unreadable /proc file. + +import ( + "net" + "os" + "runtime" + "strings" + "time" + "unsafe" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// kernel32 procs x/sys/windows v0.47.0 does not wrap. +var ( + kernel32DLL = windows.NewLazySystemDLL("kernel32.dll") + procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes") + procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx") + iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll") + procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2") +) + +func sampleFacts() *ProbeFacts { + memTotal, _, swapTotal, _ := winMemory() + diskTotal, _ := winDiskUsage() + v4, v6 := winAddresses() + return &ProbeFacts{ + Hostname: winHostname(), + OS: winOSName(), + Kernel: winKernelBuild(), + Arch: runtime.GOARCH, + Virt: winVirt(), + CPUName: winCPUName(), + CPUCores: uint32(runtime.NumCPU()), + MemTotal: memTotal, + SwapTotal: swapTotal, + DiskTotal: diskTotal, + AgentVersion: config.GetAgentDisplayVersion(), + IPv4: v4, + IPv6: v6, + } +} + +func collectMetrics() *ProbeMetrics { + probeColl.mu.Lock() + defer probeColl.mu.Unlock() + memTotal, memUsed, swapTotal, swapUsed := winMemory() + diskTotal, diskUsed := winDiskUsage() + rows := winIfaceRows() + all := make([]netCounters, 0, len(rows)) + counted := make([]netCounters, 0, len(rows)) + for _, r := range rows { + c := netCounters{name: r.name, rx: r.rx, tx: r.tx} + all = append(all, c) + if winIfaceCounts(r) { + counted = append(counted, c) + } + } + if pin := statIfacePinValue(); pin != "" { + counted = pinnedInterfaces(all, pin) + } + var rxTotal, txTotal uint64 + names := make([]string, 0, len(counted)) + for _, c := range counted { + rxTotal += c.rx + txTotal += c.tx + names = append(names, c.name) + } + rxRate, txRate := probeColl.netRate(counted, time.Now()) + return &ProbeMetrics{ + BootID: bootEpoch(winMachineGUID(), names), + Uptime: uint64(windows.DurationSinceBoot().Seconds()), + CPU: probeColl.cpuPercent(winCPUSample()), + Load: [3]float32{}, + MemTotal: memTotal, + MemUsed: memUsed, + SwapTotal: swapTotal, + SwapUsed: swapUsed, + DiskTotal: diskTotal, + DiskUsed: diskUsed, + NetRxTotal: rxTotal, + NetTxTotal: txTotal, + NetRx: rxRate, + NetTx: txRate, + TCP: 0, // no exported x/sys wrapper (see header) + UDP: 0, // no exported x/sys wrapper (see header) + Procs: winProcCount(), + } +} + +// --- registry helpers -------------------------------------------------------- + +func regGetString(root registry.Key, path, name string) string { + k, err := registry.OpenKey(root, path, registry.QUERY_VALUE) + if err != nil { + return "" + } + defer k.Close() + v, _, err := k.GetStringValue(name) + if err != nil { + return "" + } + return strings.TrimSpace(v) +} + +func winMachineGUID() string { + return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid") +} + +func winOSName() string { + const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion` + name := regGetString(registry.LOCAL_MACHINE, key, "ProductName") + if name == "" { + return "Windows" + } + // DisplayVersion ("23H2") disambiguates: ProductName still says + // "Windows 10" on most Windows 11 installs. + if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" { + name += " " + dv + } + return name +} + +func winKernelBuild() string { + const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion` + if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" { + return b + } + return "unknown" +} + +func winCPUName() string { + if n := regGetString(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" { + return n + } + return "unknown" +} + +// winVirt is best-effort: the BIOS strings Windows itself surfaces. +// No match reads "unknown" (Windows guests of a hypervisor that hides +// them are indistinguishable from bare metal this way). +func winVirt() string { + const bios = `HARDWARE\DESCRIPTION\System\BIOS` + hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") + + " " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName")) + for _, m := range []struct{ word, name string }{ + {"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"}, + {"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"}, + {"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"}, + } { + if strings.Contains(hay, m.word) { + return m.name + } + } + return "unknown" +} + +func winHostname() string { + if h, err := os.Hostname(); err == nil && h != "" { + return h + } + return "unknown" +} + +// --- cpu / memory -------------------------------------------------------------- + +// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ. +func filetime100ns(ft windows.Filetime) uint64 { + return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime) +} + +// winCPUSample reads the cumulative system times as one cpuSample: +// total = kernel + user (kernel already includes idle, like jiffies). +func winCPUSample() *cpuSample { + var idle, kernel, user windows.Filetime + r, _, _ := procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user))) + if r == 0 { + return nil + } + return &cpuSample{ + total: filetime100ns(kernel) + filetime100ns(user), + idle: filetime100ns(idle), + } +} + +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +// usedFromTotalAvail is the used-column discipline as a pure +// function: used = total - avail, saturating at 0. +func usedFromTotalAvail(total, avail uint64) uint64 { + if avail > total { + return 0 + } + return total - avail +} + +func winMemory() (total, used, swapTotal, swapUsed uint64) { + var ms memoryStatusEx + ms.Length = uint32(unsafe.Sizeof(ms)) + r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms))) + if r == 0 { + return 0, 0, 0, 0 + } + return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys), + ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile) +} + +// --- disk (df口径) ----------------------------------------------------------- + +// driveSpace is one fixed drive's GetDiskFreeSpaceEx result. +type driveSpace struct{ total, free uint64 } + +// sumDriveSpace aggregates fixed drives the df way: used = total - +// free (the caller-available figure is deliberately not consulted). +func sumDriveSpace(ds []driveSpace) (total, used uint64) { + for _, d := range ds { + total += d.total + used += usedFromTotalAvail(d.total, d.free) + } + return total, used +} + +// splitDriveStrings decodes the GetLogicalDriveStrings multi-string +// buffer (NUL-separated roots, double-NUL terminated). Pure, so the +// layout is unit-testable off-device. +func splitDriveStrings(buf []uint16) []string { + var out []string + start := 0 + for i, c := range buf { + if c != 0 { + continue + } + if i == start { + break // double NUL: end of the list + } + out = append(out, windows.UTF16ToString(buf[start:i])) + start = i + 1 + } + return out +} + +// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives. +func fixedDriveRoots() []string { + n, err := windows.GetLogicalDriveStrings(0, nil) + if err != nil || n == 0 { + return nil + } + buf := make([]uint16, n) + n, err = windows.GetLogicalDriveStrings(n, &buf[0]) + if err != nil { + return nil + } + var roots []string + for _, s := range splitDriveStrings(buf[:n]) { + p, err := windows.UTF16PtrFromString(s) + if err != nil { + continue + } + if windows.GetDriveType(p) == windows.DRIVE_FIXED { + roots = append(roots, s) + } + } + return roots +} + +func winDiskUsage() (total, used uint64) { + var spaces []driveSpace + for _, root := range fixedDriveRoots() { + p, err := windows.UTF16PtrFromString(root) + if err != nil { + continue + } + var freeToCaller, totalBytes, totalFree uint64 + if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil { + continue + } + spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree}) + } + return sumDriveSpace(spaces) +} + +// --- network: counted set ------------------------------------------------------ + +// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2. +type winIfaceRow struct { + name string + ifType uint32 + operUp bool + rx, tx uint64 +} + +// winIfaceCounts applies the counted-set rule to one row (see the +// file header for how it approximates the Linux three-layer filter). +func winIfaceCounts(r winIfaceRow) bool { + if !r.operUp { + return false + } + if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK { + return false + } + return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD || + r.ifType == windows.IF_TYPE_IEEE80211 +} + +// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics +// derives both the all-interfaces list (for the operator pin) and the +// counted set from it. +func winIfaceRows() []winIfaceRow { + var table *windows.MibIfTable2 + if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil { + return nil + } + defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table))) + n := int(table.NumEntries) + if n == 0 { + return nil + } + rows := unsafe.Slice(&table.Table[0], n) + out := make([]winIfaceRow, 0, n) + for i := range rows { + r := &rows[i] + out = append(out, winIfaceRow{ + name: windows.UTF16ToString(r.Alias[:]), + ifType: r.Type, + operUp: r.OperStatus == windows.IfOperStatusUp, + rx: r.InOctets, + tx: r.OutOctets, + }) + } + return out +} + +// --- processes ----------------------------------------------------------------- + +func winProcCount() uint32 { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 0 + } + defer windows.CloseHandle(snap) + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + if err := windows.Process32First(snap, &pe); err != nil { + return 0 + } + var n uint32 = 1 + for { + if err := windows.Process32Next(snap, &pe); err != nil { + break + } + n++ + } + return n +} + +// --- addresses ------------------------------------------------------------------- + +// winAddresses picks one address per family from up, non-loopback +// interfaces: public preferred over private, like the Linux picker — +// only without its /proc inputs (no operstate file, no temporary- +// address flags; net.Interfaces' own flags do the loopback cut). +func winAddresses() (string, string) { + ifaces, err := net.Interfaces() + if err != nil { + return "", "" + } + var cands []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 || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() { + continue + } + cands = append(cands, ip) + } + } + pick := func(v6 bool) string { + var best net.IP + for _, ip := range cands { + if (ip.To4() == nil) != v6 { + continue + } + if best == nil { + best = ip + continue + } + if isPublicIP(ip) && !isPublicIP(best) { + best = ip + } + } + if best == nil { + return "" + } + return best.String() + } + return pick(false), pick(true) +} diff --git a/internal/agent/probe_windows_test.go b/internal/agent/probe_windows_test.go new file mode 100644 index 0000000..761e79e --- /dev/null +++ b/internal/agent/probe_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package agent + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +// The Windows sampler's pure helpers: the Win32 calls themselves need +// a real machine, but the accounting math layered on their results is +// pinned here. + +func TestFiletime100nsAndCPUSampleDiff(t *testing.T) { + lo := windows.Filetime{LowDateTime: 0x00000005, HighDateTime: 0x00000002} + if got := filetime100ns(lo); got != 0x200000005 { + t.Fatalf("filetime100ns = %#x, want 0x200000005", got) + } + // The shared differ over Windows-style counters (kernel includes + // idle): idle advanced by the full window -> 0% busy; then a + // window where only a quarter of the time is idle -> 75%. + c := &probeCollector{} + first := &cpuSample{total: 1000, idle: 400} + if got := c.cpuPercent(first); got != 0 { + t.Fatalf("first sample = %v, want 0 (baseline only)", got) + } + idleAll := &cpuSample{total: 2000, idle: 1400} + if got := c.cpuPercent(idleAll); got != 0 { + t.Fatalf("all-idle window = %v, want 0", got) + } + busy := &cpuSample{total: 3000, idle: 1650} + if got := c.cpuPercent(busy); got != 75 { + t.Fatalf("busy window = %v, want 75", got) + } +} + +func TestUsedFromTotalAvail(t *testing.T) { + if got := usedFromTotalAvail(100, 30); got != 70 { + t.Fatalf("used = %d, want 70", got) + } + if got := usedFromTotalAvail(100, 100); got != 0 { + t.Fatalf("used = %d, want 0", got) + } + if got := usedFromTotalAvail(100, 130); got != 0 { + t.Fatalf("used saturates, got %d", got) + } +} + +func TestSumDriveSpaceDfSemantics(t *testing.T) { + total, used := sumDriveSpace([]driveSpace{ + {total: 1000, free: 250}, + {total: 2000, free: 2000}, + {total: 500, free: 900}, // inconsistent source clamps to full + }) + if total != 3500 || used != 1250 { + t.Fatalf("sumDriveSpace = %d/%d, want 3500/1250", total, used) + } +} + +func TestSplitDriveStrings(t *testing.T) { + mk := func(ss ...string) []uint16 { + var out []uint16 + for _, s := range ss { + for _, r := range s { + out = append(out, uint16(r)) + } + out = append(out, 0) + } + return append(out, 0) + } + got := splitDriveStrings(mk(`C:\`, `D:\`)) + if len(got) != 2 || got[0] != `C:\` || got[1] != `D:\` { + t.Fatalf("splitDriveStrings = %q, want [C:\\ D:\\]", got) + } + if got := splitDriveStrings(mk()); len(got) != 0 { + t.Fatalf("empty buffer = %q, want none", got) + } +} + +func TestWinIfaceCountsRule(t *testing.T) { + cases := []struct { + name string + row winIfaceRow + want bool + }{ + {"up ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: true}, true}, + {"up wifi", winIfaceRow{ifType: windows.IF_TYPE_IEEE80211, operUp: true}, true}, + {"down ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: false}, false}, + {"loopback", winIfaceRow{ifType: windows.IF_TYPE_SOFTWARE_LOOPBACK, operUp: true}, false}, + {"tunnel", winIfaceRow{ifType: 131, operUp: true}, false}, + {"ppp", winIfaceRow{ifType: 23, operUp: true}, false}, + } + for _, tc := range cases { + if got := winIfaceCounts(tc.row); got != tc.want { + t.Errorf("%s: winIfaceCounts = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/agent/update.go b/internal/agent/update.go index 45560de..c41bc5e 100644 --- a/internal/agent/update.go +++ b/internal/agent/update.go @@ -52,6 +52,15 @@ var execSelf = func(path string) error { return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ()) } +// selfUpdateSupported reports whether this platform can consume a +// master update intent. Windows agents are the standalone +// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by +// re-running install-agent.ps1: the release assets an intent points +// at are the panel's Linux binaries, so an intent delivered to a +// Windows node is acknowledged and skipped, never downloaded. +// A package variable so tests can exercise the skip on any OS. +var selfUpdateSupported = runtime.GOOS != "windows" + func (a *Agent) setUpdateReport(st agentUpdateStatus) { a.updateMu.Lock() cp := st @@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) { a.updateMu.Unlock() return } + if !selfUpdateSupported { + // Windows (see selfUpdateSupported): log once per intent and + // mark it handled so the poll does not re-log it every cycle. + // No report is filed — the master-side intent simply ages out, + // and the node updates by re-running install-agent.ps1. + a.updateMu.Lock() + if intent.RequestedAt != a.updateHandled { + a.updateHandled = intent.RequestedAt + logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS) + } + a.updateMu.Unlock() + return + } a.updateMu.Lock() if a.updateRunning || intent.RequestedAt == a.updateHandled || (intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) { diff --git a/internal/agent/update_test.go b/internal/agent/update_test.go index 05604a8..6d14f11 100644 --- a/internal/agent/update_test.go +++ b/internal/agent/update_test.go @@ -159,6 +159,39 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) { } } +func TestAgentUpdateSkippedOnUnsupportedOS(t *testing.T) { + orig := selfUpdateSupported + selfUpdateSupported = false + t.Cleanup(func() { selfUpdateSupported = orig }) + + var hits atomic.Int32 + srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits) + defer srv.Close() + a, exe := newUpdateTestAgent(t, srv.URL) + + intent := &updateIntent{RequestedAt: 46, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"} + a.maybeHandleUpdate(intent) + // A repeated delivery of the same intent is also a no-op. + a.maybeHandleUpdate(intent) + + if hits.Load() != 0 { + t.Fatalf("release server hit %d times on an unsupported OS, want 0", hits.Load()) + } + got, _ := os.ReadFile(exe) + if string(got) != "old-binary" { + t.Fatalf("exe = %q, want untouched old-binary", got) + } + a.updateMu.Lock() + handled, running := a.updateHandled, a.updateRunning + a.updateMu.Unlock() + if handled != 46 || running { + t.Fatalf("handled=%d running=%v, want handled=46 running=false", handled, running) + } + if rep := a.currentUpdateReport(); rep != nil { + t.Fatalf("report = %+v, want none for a skipped intent", rep) + } +} + var errExecBoom = &execBoomError{} type execBoomError struct{} diff --git a/internal/config/config.go b/internal/config/config.go index 86bc0e2..b9d6f75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -125,16 +125,30 @@ func GetDisplayVersion() string { return defaultDisplayVersion } +// bareDisplayVersion is GetDisplayVersion without a single leading +// V/v. Release builds inject the number either bare ("2.1.0") or in +// the master's V-form ("V2.1.0"); the letter-prefixed getters below +// must compose from the bare form so the letter never doubles +// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when +// the V-form was injected). +func bareDisplayVersion() string { + v := GetDisplayVersion() + if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') { + return v[1:] + } + return v +} + // GetMasterDisplayVersion is the master's display version ("V1.0.0"), // shown in the panel's version slot. func GetMasterDisplayVersion() string { - return "V" + GetDisplayVersion() + return "V" + bareDisplayVersion() } // GetAgentDisplayVersion is the agent's display version ("A1.0.0"), // reported in agent telemetry and shown on agent node rows. func GetAgentDisplayVersion() string { - return "A" + GetDisplayVersion() + return "A" + bareDisplayVersion() } // ParseDisplayVersion parses a display version into its three numeric diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fe6b46e..b4b5976 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,6 +75,20 @@ func TestDisplayVersionDefaultsAndOverride(t *testing.T) { if got := GetAgentDisplayVersion(); got != "A1.0.1" { t.Fatalf("injected agent display version = %q, want A1.0.1", got) } + + // A release build may inject the number in the master's V-form: + // neither getter may double its letter ("VV1.0.2" / "AV1.0.2"). + displayVersion = "V1.0.2" + if got := GetMasterDisplayVersion(); got != "V1.0.2" { + t.Fatalf("V-injected master display version = %q, want V1.0.2", got) + } + if got := GetAgentDisplayVersion(); got != "A1.0.2" { + t.Fatalf("V-injected agent display version = %q, want A1.0.2", got) + } + displayVersion = "v1.0.3" + if got := GetAgentDisplayVersion(); got != "A1.0.3" { + t.Fatalf("v-injected agent display version = %q, want A1.0.3", got) + } } func TestParseDisplayVersion(t *testing.T) { diff --git a/internal/sub/service.go b/internal/sub/service.go index 2c45a97..0528b6d 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { case "tls": applyShareTLSParams(stream, params) case "reality": - applyShareRealityParams(stream, params) + applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params) default: params["security"] = "none" } @@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string case "tls": applyShareTLSParams(stream, params) case "reality": - applyShareRealityParams(stream, params) + applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params) if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow { params["flow"] = client.Flow } @@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string { return pin } -func applyShareRealityParams(stream map[string]any, params map[string]string) { +// singboxFragmentServerName returns the tls.server_name of the +// inbound's stored sing-box fragment — the ONE name the sing-box +// REALITY server actually serves. The converter puts exactly one +// name there (realitySettings.serverNames[0], see internal/singbox) +// while the legacy settings may list dozens. "" when the inbound has +// no fragment, the fragment is unparseable, or it carries no server +// name; callers then keep the historical behaviour. +func singboxFragmentServerName(singboxConfig string) string { + if strings.TrimSpace(singboxConfig) == "" { + return "" + } + var fragment map[string]any + if err := json.Unmarshal([]byte(singboxConfig), &fragment); err != nil { + return "" + } + if tlsBlock, ok := fragment["tls"].(map[string]any); ok { + if name, ok := tlsBlock["server_name"].(string); ok { + return strings.TrimSpace(name) + } + } + return "" +} + +func applyShareRealityParams(stream map[string]any, fragmentSNI string, params map[string]string) { params["security"] = "reality" realitySetting, _ := stream["realitySettings"].(map[string]any) realitySettings, _ := searchKey(realitySetting, "settings") @@ -1710,6 +1733,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) { params["sni"], _ = sNames[random.Num(len(sNames))].(string) } } + // The fragment's server_name wins over the pick above: xray + // serves the whole serverNames list, so a random entry was + // safe there, but the sing-box core serves only the + // fragment's single name — any other entry is refused as an + // invalid REALITY connection (the PPF-L3 outage: 29 listed + // names, core serving only the first). + if fragmentSNI != "" { + params["sni"] = fragmentSNI + } if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { params["pbk"], _ = pbkValue.(string) } diff --git a/internal/sub/service_sharelink_test.go b/internal/sub/service_sharelink_test.go index a2741cf..caea7be 100644 --- a/internal/sub/service_sharelink_test.go +++ b/internal/sub/service_sharelink_test.go @@ -89,6 +89,60 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) { } } +// Scope D: with several serverNames, the generated link's sni must be +// the sing-box fragment's tls.server_name — the only name the sing-box +// REALITY server serves (the converter stores serverNames[0] there). +// The historical behaviour picked a uniformly random list entry per +// generation, which xray tolerated (it serves the whole list) but +// sing-box refuses as an invalid connection. Without a fragment the +// sni still comes from the list. +func TestGenVlessLink_RealitySniPrefersSingboxFragment(t *testing.T) { + stream := `{ + "network":"tcp","security":"reality", + "tcpSettings":{"header":{"type":"none"}}, + "realitySettings":{ + "serverNames":["amd.example.com","connect-sit.amd.example.com","www.amd.example.com"], + "shortIds":["ab12cd"], + "settings":{"publicKey":"PBKvalue","fingerprint":"firefox"} + } + }` + s := &SubService{} + inbound := shareLinkInbound(stream) + inbound.SingboxConfig = `{"type":"vless","tag":"in","listen":"::","listen_port":443,` + + `"tls":{"enabled":true,"server_name":"amd.example.com",` + + `"reality":{"enabled":true,"short_id":["ab12cd"]}}}` + + // The losing branch is a random pick: sample enough generations + // that a regression to it cannot survive by luck (2/3 wrong per + // draw; 30 draws make a false pass ~2e-6). + for i := 0; i < 30; i++ { + link := s.genVlessLink(inbound, "user") + u, err := url.Parse(link) + if err != nil { + t.Fatalf("parse link %q: %v", link, err) + } + if got := u.Query().Get("sni"); got != "amd.example.com" { + t.Fatalf("draw %d: sni = %q, want the fragment's amd.example.com\nlink: %s", i, got, link) + } + } + + // No fragment (or an unusable one): historical behaviour — the + // sni is some entry of the serverNames list. + for _, frag := range []string{"", "{not json", `{"type":"vless"}`} { + inbound.SingboxConfig = frag + link := s.genVlessLink(inbound, "user") + u, err := url.Parse(link) + if err != nil { + t.Fatalf("parse link %q: %v", link, err) + } + switch got := u.Query().Get("sni"); got { + case "amd.example.com", "connect-sit.amd.example.com", "www.amd.example.com": + default: + t.Fatalf("fragment %q: sni = %q, want a serverNames entry", frag, got) + } + } +} + // realityTwoClientInbound builds a reality VLESS inbound carrying two clients // with distinct subIds. func realityTwoClientInbound() *model.Inbound { diff --git a/internal/web/service/node_agent_telemetry_test.go b/internal/web/service/node_agent_telemetry_test.go index d14a1cf..ec35f16 100644 --- a/internal/web/service/node_agent_telemetry_test.go +++ b/internal/web/service/node_agent_telemetry_test.go @@ -7,6 +7,28 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) +// The panel-version cell must read as the bare A-form across the +// fleet: pre-fix agents report the doubled "AV2.1.0" and are repaired +// at view assembly; legacy build versions keep their "agent " prefix. +func TestAgentVersionCell(t *testing.T) { + cases := map[string]string{ + "A2.1.0": "A2.1.0", + "A1.0.0": "A1.0.0", + "AV2.1.0": "A2.1.0", + "AV2.0.1": "A2.0.1", + "agent AV2.0.3": "A2.0.3", + "3.7.0-agent.1": "agent 3.7.0-agent.1", + "agent A2.1.0": "A2.1.0", + " A2.1.0 ": "A2.1.0", + "some-dev-build": "agent some-dev-build", + } + for in, want := range cases { + if got := agentVersionCell(in); got != want { + t.Errorf("agentVersionCell(%q) = %q, want %q", in, got, want) + } + } +} + // P13: annotateAgentTelemetry overlays the agent-reported heartbeat // telemetry onto agent node views only — panel version becomes // "agent ", xray version/uptime/latency/CPU/mem/IPs take the diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index 7da56f6..d86b0fa 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -190,6 +190,30 @@ func annotateNodeTypes(views []*NodeView) { } } +// agentVersionCell renders one agent's reported version for the +// node list's panel-version cell, uniformly across the fleet: +// - current agents report the A-series display version and are +// shown bare ("A2.1.0"); +// - agents built while releases injected the V-form number report +// the doubled "AV2.1.0" (see config.GetAgentDisplayVersion) and +// are repaired to the bare A-form here, so those nodes read +// correctly without waiting for their next agent update; +// - pre-display-version agents report the internal build version +// and keep the historical "agent " prefix. +func agentVersionCell(reported string) string { + s := strings.TrimSpace(reported) + s = strings.TrimPrefix(s, "agent ") + if len(s) > 2 && (s[0] == 'A' || s[0] == 'a') && (s[1] == 'V' || s[1] == 'v') { + if _, ok := config.ParseDisplayVersion(s[1:]); ok { + return "A" + s[2:] + } + } + if _, ok := config.ParseDisplayVersion(s); ok && strings.HasPrefix(s, "A") { + return s + } + return "agent " + s +} + // annotateAgentTelemetry overlays the latest heartbeat telemetry reported // by each node's bound agent onto its view (P13): xray version, the // "agent " panel-version label, host uptime, node->master latency, @@ -244,14 +268,7 @@ func annotateAgentTelemetry(views []*NodeView) { v.SingboxVersion = ag.SingboxVersion } if ag.AgentVersion != "" { - // New agents report the A-series display version, shown - // verbatim; older agents report the internal build version - // and keep the historical "agent " prefix. - if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") { - v.PanelVersion = ag.AgentVersion - } else { - v.PanelVersion = "agent " + ag.AgentVersion - } + v.PanelVersion = agentVersionCell(ag.AgentVersion) v.AgentUpdateState = ag.UpdateState v.AgentUpdateError = ag.UpdateError v.AgentUpdateTarget = ag.UpdateTarget