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

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

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

464 lines
13 KiB
Go

//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)
}