Files
3x-ui-agent/internal/agent/probe_windows.go
T
Muse 92dfdc542c v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed
Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which
killed the agent on its first collection after a successful register.
Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+
FreeMibIfTable2 as one gated pair) now resolve through sourceGate
(Find once, missing = source disabled with one warning); the iface
pair is gated together so a missing free proc never leaks the
system table. collectMetrics additionally runs every source through
guardedCall so any residual panic zeroes that source alone.
Pure gate/guard logic lives in probe_guard.go (no build tag) with
Linux-runnable regression tests.
2026-10-23 14:26:22 +00:00

531 lines
16 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.
// Optional procedures are resolved through source gates
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
// missing export, and trimmed images do ship DLLs without the
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
// collection (V2.2.1 ①). A missing procedure now disables just
// its source, and collectMetrics additionally guards each source
// so a panic anywhere degrades that source alone.
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, plus the
// iphlpapi pair this file calls directly (gated below, so x/sys'
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
// missing export — is deliberately not used).
var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
)
// Availability gates for the optional procedures above. A LazyProc
// must never be Call()ed before its gate has proven the export
// exists: Call must-finds, so a missing procedure is a process-killing
// panic, not an error.
var (
gateSystemTimes sourceGate
gateMemoryStatus sourceGate
gateIfTable sourceGate
)
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
// the export WITHOUT the mustFind panic baked into Call/Addr.
func procProbe(p *windows.LazyProc) func() error {
return func() error { return p.Find() }
}
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,
}
}
// memSnapshot / diskSnapshot pack the multi-value samplers so each
// can run as one guarded source.
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
type diskSnapshot struct{ total, used uint64 }
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
// Every source runs guarded: a panic in one (a Win32 surface the
// gates did not foresee) zeroes that source alone instead of
// killing the agent on its first collection.
mem := guardedCall("memory", func() memSnapshot {
total, used, swapTotal, swapUsed := winMemory()
return memSnapshot{total, used, swapTotal, swapUsed}
})
disk := guardedCall("disk", func() diskSnapshot {
total, used := winDiskUsage()
return diskSnapshot{total, used}
})
rows := guardedCall("interfaces", 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: guardedCall("uptime", func() uint64 {
return uint64(windows.DurationSinceBoot().Seconds())
}),
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
Load: [3]float32{},
MemTotal: mem.total,
MemUsed: mem.used,
SwapTotal: mem.swapTotal,
SwapUsed: mem.swapUsed,
DiskTotal: disk.total,
DiskUsed: disk.used,
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: guardedCall("processes", 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 {
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
return nil
}
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) {
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
return 0, 0, 0, 0
}
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 {
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
// without FreeMibIfTable2 would leak the system-allocated table
// on every poll, and either export missing must degrade the
// source to zero rows instead of panicking — the trimmed Tianyi
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
if err := procGetIfTable2Ex.Find(); err != nil {
return err
}
return procFreeMibIfTable2.Find()
}) {
return nil
}
var table *windows.MibIfTable2
r, _, _ := procGetIfTable2Ex.Call(
uintptr(windows.MibIfTableNormal),
uintptr(unsafe.Pointer(&table)))
if r != 0 || 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)
}