feat: Windows agent inventory
Server Deploy / deploy (push) Successful in 9s
Agent Release / build (push) Successful in 1m39s
Agent Release / msi (push) Successful in 36s

This commit is contained in:
2026-07-28 11:29:08 +01:00
parent 60a4ed9aab
commit c9dab99271
4 changed files with 193 additions and 6 deletions
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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
+3 -2
View File
@@ -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.
+183
View File
@@ -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
}