diff --git a/agent/go.mod b/agent/go.mod index 275f341..009060a 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -3,13 +3,13 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent go 1.26 require ( + golang.org/x/sys v0.20.0 google.golang.org/grpc v1.64.0 gopkg.in/yaml.v3 v3.0.1 ) require ( golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/agent/internal/inventory/collect_linux.go b/agent/internal/inventory/collect_linux.go index 7601d04..df2291f 100644 --- a/agent/internal/inventory/collect_linux.go +++ b/agent/internal/inventory/collect_linux.go @@ -144,11 +144,14 @@ func partitions() []pb.PartitionReport { if syscall.Statfs(fields[1], &st) != nil { continue } - total := st.Blocks * uint64(st.Bsize) - free := st.Bavail * uint64(st.Bsize) + bsize := uint64(st.Bsize) + total := st.Blocks * bsize + // Bfree, not Bavail: the difference is the root-reserved 5% on ext4, + // which is not used space. df counts it the same way. + used := (st.Blocks - st.Bfree) * bsize out = append(out, pb.PartitionReport{ Device: fields[0], Mountpoint: fields[1], Fstype: fields[2], - TotalBytes: total, UsedBytes: total - free, + TotalBytes: total, UsedBytes: used, }) } return out diff --git a/agent/internal/inventory/collect_other.go b/agent/internal/inventory/collect_other.go index 720d14b..fa45061 100644 --- a/agent/internal/inventory/collect_other.go +++ b/agent/internal/inventory/collect_other.go @@ -1,6 +1,7 @@ -//go:build !linux +//go:build !linux && !windows -// Inventory collection is Linux-only. This no-op stands in everywhere else. +// Inventory collection has Linux and Windows implementations. This no-op stands +// in everywhere else. // // The build constraint above is load-bearing: "_other" is not a GOOS suffix, so // without it this file compiles on Linux too and collides with collect_linux.go. diff --git a/agent/internal/inventory/collect_windows.go b/agent/internal/inventory/collect_windows.go new file mode 100644 index 0000000..1369387 --- /dev/null +++ b/agent/internal/inventory/collect_windows.go @@ -0,0 +1,183 @@ +package inventory + +import ( + "fmt" + "runtime" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetSystemTimes = kernel32.NewProc("GetSystemTimes") + // x/sys/windows exposes neither of these two, so they are bound by hand. + procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") +) + +func collect(r *pb.InventoryReport, includeStatic bool) { + r.CPU.UsagePct = cpuUsage() + // Windows has no load average. Left at zero; the UI already treats it as + // optional because it is omitempty on the wire. + + m := memoryStatus() + if m.TotalPhys > m.AvailPhys { + r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys + } + // TotalPageFile is the commit limit — physical memory plus the pagefile — + // so the pagefile alone is the difference. + swapTotal := sub(m.TotalPageFile, m.TotalPhys) + swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys)) + if swapUsed > swapTotal { + swapUsed = swapTotal + } + r.SwapUsed = swapUsed + + if includeStatic { + r.Memory.TotalBytes = m.TotalPhys + r.SwapTotal = swapTotal + r.CPU.Model, r.CPU.Cores = cpuStatic() + r.Kernel = kernel() + r.Partitions = partitions() + } +} + +func sub(a, b uint64) uint64 { + if a > b { + return a - b + } + return 0 +} + +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +func memoryStatus() memoryStatusEx { + var m memoryStatusEx + m.Length = uint32(unsafe.Sizeof(m)) + r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m))) + if r == 0 { + return memoryStatusEx{} + } + return m +} + +func systemTimes() (idle, total uint64, ok bool) { + var idleFT, kernelFT, userFT windows.Filetime + r, _, _ := procGetSystemTimes.Call( + uintptr(unsafe.Pointer(&idleFT)), + uintptr(unsafe.Pointer(&kernelFT)), + uintptr(unsafe.Pointer(&userFT)), + ) + if r == 0 { + return 0, 0, false + } + ft := func(f windows.Filetime) uint64 { + return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) + } + // Kernel time already includes idle time, so kernel+user is the whole. + return ft(idleFT), ft(kernelFT) + ft(userFT), true +} + +func cpuUsage() float64 { + i1, t1, ok := systemTimes() + if !ok { + return 0 + } + time.Sleep(100 * time.Millisecond) + i2, t2, ok := systemTimes() + if !ok || t2 <= t1 { + return 0 + } + return (1 - float64(i2-i1)/float64(t2-t1)) * 100 +} + +func cpuStatic() (model string, cores int) { + cores = runtime.NumCPU() + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) + if err != nil { + return + } + defer k.Close() + if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil { + model = s + } + return +} + +func kernel() string { + v := windows.RtlGetVersion() + return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber) +} + +func partitions() []pb.PartitionReport { + buf := make([]uint16, 256) + n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0]) + if err != nil || n == 0 { + return nil + } + + var out []pb.PartitionReport + for _, root := range splitNullStrings(buf[:n]) { + rootPtr, err := windows.UTF16PtrFromString(root) + if err != nil { + continue + } + // Fixed disks only: network shares can hang, and removable drives + // would appear and vanish between snapshots. + if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED { + continue + } + + var free, total, totalFree uint64 + if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil { + continue + } + + fsBuf := make([]uint16, 32) + var fstype string + if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil { + fstype = windows.UTF16ToString(fsBuf) + } + + out = append(out, pb.PartitionReport{ + Device: root, + Mountpoint: root, + Fstype: fstype, + TotalBytes: total, + UsedBytes: total - totalFree, + }) + } + return out +} + +// splitNullStrings splits the NUL-separated, double-NUL-terminated block that +// GetLogicalDriveStrings writes. +func splitNullStrings(b []uint16) []string { + var out []string + start := 0 + for i, c := range b { + if c != 0 { + continue + } + if i > start { + out = append(out, windows.UTF16ToString(b[start:i])) + } + start = i + 1 + } + return out +}