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.
531 lines
16 KiB
Go
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)
|
|
}
|