Windows Agent (v1.1.0):
- Software-Inventar aus Windows-Registry (64-bit + 32-bit)
Sammelt Name, Version, Hersteller, InstallDate aller installierten Apps
Filtert SystemComponent-Einträge heraus, sortiert alphabetisch
Funktioniert unter SYSTEM-Account (kein winget nötig)
- Version auf 1.1.0 gehoben
Backend:
- customer_wau_rules Tabelle (Migration automatisch)
- WAU-Regeln pro Kunde (allow/block, winget-ID + Anzeigename)
- GET/POST/DELETE /api/v1/customers/{id}/wau/rules
- GET /api/v1/customers/{id}/wau/inventory (SQL-Aggregation aus Agenten-Daten)
- GET /api/v1/customers/{id}/wau/included (Plaintext für WAU)
- GET /api/v1/customers/{id}/wau/excluded (Plaintext für WAU)
Frontend:
- Kunden-Seite: Tab-Navigation (Agents | API-Keys | WAU)
- WAU-Tab pro Kunde: Allowlist + Blocklist mit Inline-Assign aus Inventar
Allow/Block-Buttons direkt in Inventar-Zeile, winget-ID vorgeschlagen
Installationsbefehl mit externer Backend-URL aus Systemeinstellungen
- Windows-Panel Software-Tab: Installiert-Liste aus Registry-Daten
- Windows-Panel WAU-Tab: Status (installiert/Task/letzter Lauf)
WAU installieren via MSI-Download + msiexec (synchron)
Jetzt ausführen, Log anzeigen
Gewünschten Zustand herstellen (Allowlist auf Client installieren)
444 lines
11 KiB
Go
444 lines
11 KiB
Go
package collector
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
"golang.org/x/sys/windows/registry"
|
|
)
|
|
|
|
// SystemData ist das Haupt-Daten-Struct das an das Backend gesendet wird
|
|
type SystemData struct {
|
|
Windows *WindowsData `json:"windows,omitempty"`
|
|
}
|
|
|
|
type WindowsData struct {
|
|
Hostname string `json:"hostname"`
|
|
OSVersion string `json:"os_version"`
|
|
OSBuild string `json:"os_build"`
|
|
UptimeSecs int64 `json:"uptime_seconds"`
|
|
CPU CPUInfo `json:"cpu"`
|
|
Memory MemoryInfo `json:"memory"`
|
|
Disks []DiskInfo `json:"disks"`
|
|
Services []SvcInfo `json:"services,omitempty"`
|
|
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
|
|
Domain string `json:"domain,omitempty"`
|
|
LastUpdate time.Time `json:"last_update"`
|
|
}
|
|
|
|
type CPUInfo struct {
|
|
Model string `json:"model"`
|
|
Cores int `json:"cores"`
|
|
LogicalCores int `json:"logical_cores"`
|
|
LoadPercent float64 `json:"load_percent"`
|
|
}
|
|
|
|
type MemoryInfo struct {
|
|
TotalBytes uint64 `json:"total_bytes"`
|
|
AvailableBytes uint64 `json:"available_bytes"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
}
|
|
|
|
type DiskInfo struct {
|
|
Drive string `json:"drive"`
|
|
Label string `json:"label,omitempty"`
|
|
TotalBytes uint64 `json:"total_bytes"`
|
|
FreeBytes uint64 `json:"free_bytes"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
Filesystem string `json:"filesystem,omitempty"`
|
|
}
|
|
|
|
type SvcInfo struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
Status string `json:"status"`
|
|
StartType string `json:"start_type"`
|
|
}
|
|
|
|
type SoftwareInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version,omitempty"`
|
|
Publisher string `json:"publisher,omitempty"`
|
|
InstallDate string `json:"install_date,omitempty"`
|
|
}
|
|
|
|
// MEMORYSTATUSEX fuer GlobalMemoryStatusEx
|
|
type memoryStatusEx struct {
|
|
dwLength uint32
|
|
dwMemoryLoad uint32
|
|
ullTotalPhys uint64
|
|
ullAvailPhys uint64
|
|
ullTotalPageFile uint64
|
|
ullAvailPageFile uint64
|
|
ullTotalVirtual uint64
|
|
ullAvailVirtual uint64
|
|
ullAvailExtendedVirtual uint64
|
|
}
|
|
|
|
var (
|
|
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
|
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
|
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
|
|
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
|
)
|
|
|
|
// Collect sammelt alle Systemdaten
|
|
func Collect() (*SystemData, error) {
|
|
wd := &WindowsData{
|
|
LastUpdate: time.Now(),
|
|
}
|
|
|
|
// Hostname
|
|
if h, err := os.Hostname(); err == nil {
|
|
wd.Hostname = h
|
|
}
|
|
|
|
// OS-Version
|
|
wd.OSVersion, wd.OSBuild = getOSVersion()
|
|
|
|
// Uptime
|
|
wd.UptimeSecs = getUptime()
|
|
|
|
// CPU
|
|
wd.CPU = getCPUInfo()
|
|
|
|
// Memory
|
|
wd.Memory = getMemoryInfo()
|
|
|
|
// Disks
|
|
wd.Disks = getDiskInfo()
|
|
|
|
// Domain
|
|
wd.Domain = getDomain()
|
|
|
|
// Installierte Software (Registry)
|
|
wd.InstalledSoftware = getInstalledSoftware()
|
|
|
|
return &SystemData{Windows: wd}, nil
|
|
}
|
|
|
|
func getOSVersion() (version, build string) {
|
|
// Registry: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion
|
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
|
`SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
|
|
if err != nil {
|
|
return "Windows (unbekannt)", ""
|
|
}
|
|
defer k.Close()
|
|
|
|
productName, _, _ := k.GetStringValue("ProductName")
|
|
displayVersion, _, _ := k.GetStringValue("DisplayVersion")
|
|
currentBuild, _, _ := k.GetStringValue("CurrentBuild")
|
|
ubr, _, _ := k.GetIntegerValue("UBR")
|
|
|
|
// Windows 11 erkennen: Build >= 22000, aber ProductName sagt noch "Windows 10"
|
|
buildNum := 0
|
|
fmt.Sscanf(currentBuild, "%d", &buildNum)
|
|
if buildNum >= 22000 && strings.Contains(productName, "Windows 10") {
|
|
productName = strings.Replace(productName, "Windows 10", "Windows 11", 1)
|
|
}
|
|
|
|
if displayVersion != "" {
|
|
version = fmt.Sprintf("%s %s", productName, displayVersion)
|
|
} else {
|
|
version = productName
|
|
}
|
|
if ubr > 0 {
|
|
build = fmt.Sprintf("%s.%d", currentBuild, ubr)
|
|
} else {
|
|
build = currentBuild
|
|
}
|
|
return
|
|
}
|
|
|
|
func getUptime() int64 {
|
|
ms, _, _ := procGetTickCount64.Call()
|
|
return int64(ms) / 1000
|
|
}
|
|
|
|
func getCPUInfo() CPUInfo {
|
|
info := CPUInfo{}
|
|
|
|
// Modell aus Registry
|
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
|
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
|
if err == nil {
|
|
defer k.Close()
|
|
info.Model, _, _ = k.GetStringValue("ProcessorNameString")
|
|
info.Model = strings.TrimSpace(info.Model)
|
|
}
|
|
|
|
// Kern-Anzahl
|
|
info.LogicalCores = getLogicalCoreCount()
|
|
info.Cores = getPhysicalCoreCount()
|
|
if info.Cores == 0 {
|
|
info.Cores = info.LogicalCores
|
|
}
|
|
|
|
// CPU-Last via PowerShell (einmalig, nicht ideal aber zuverlässig)
|
|
info.LoadPercent = getCPULoad()
|
|
|
|
return info
|
|
}
|
|
|
|
type systemInfo struct {
|
|
wProcessorArchitecture uint16
|
|
wReserved uint16
|
|
dwPageSize uint32
|
|
lpMinimumApplicationAddress uintptr
|
|
lpMaximumApplicationAddress uintptr
|
|
dwActiveProcessorMask uintptr
|
|
dwNumberOfProcessors uint32
|
|
dwProcessorType uint32
|
|
dwAllocationGranularity uint32
|
|
wProcessorLevel uint16
|
|
wProcessorRevision uint16
|
|
}
|
|
|
|
func getLogicalCoreCount() int {
|
|
var si systemInfo
|
|
kernel32.NewProc("GetSystemInfo").Call(uintptr(unsafe.Pointer(&si)))
|
|
return int(si.dwNumberOfProcessors)
|
|
}
|
|
|
|
func getPhysicalCoreCount() int {
|
|
out, err := runPS("(Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfCores -Sum).Sum")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(strings.TrimSpace(out))
|
|
return n
|
|
}
|
|
|
|
func getCPULoad() float64 {
|
|
// GetSystemTimes: idle, kernel (includes idle), user
|
|
type filetime struct{ lo, hi uint32 }
|
|
var idle, kernel, user filetime
|
|
|
|
r, _, _ := procGetSystemTimes.Call(
|
|
uintptr(unsafe.Pointer(&idle)),
|
|
uintptr(unsafe.Pointer(&kernel)),
|
|
uintptr(unsafe.Pointer(&user)),
|
|
)
|
|
if r == 0 {
|
|
return 0
|
|
}
|
|
|
|
toUint64 := func(f filetime) uint64 { return uint64(f.hi)<<32 | uint64(f.lo) }
|
|
|
|
t1idle := toUint64(idle)
|
|
t1kernel := toUint64(kernel)
|
|
t1user := toUint64(user)
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
r, _, _ = procGetSystemTimes.Call(
|
|
uintptr(unsafe.Pointer(&idle)),
|
|
uintptr(unsafe.Pointer(&kernel)),
|
|
uintptr(unsafe.Pointer(&user)),
|
|
)
|
|
if r == 0 {
|
|
return 0
|
|
}
|
|
|
|
t2idle := toUint64(idle)
|
|
t2kernel := toUint64(kernel)
|
|
t2user := toUint64(user)
|
|
|
|
idleDelta := t2idle - t1idle
|
|
kernelDelta := t2kernel - t1kernel
|
|
userDelta := t2user - t1user
|
|
|
|
total := kernelDelta + userDelta
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
busy := total - idleDelta
|
|
return float64(busy) / float64(total) * 100.0
|
|
}
|
|
|
|
func getMemoryInfo() MemoryInfo {
|
|
var ms memoryStatusEx
|
|
ms.dwLength = uint32(unsafe.Sizeof(ms))
|
|
procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
|
|
|
used := ms.ullTotalPhys - ms.ullAvailPhys
|
|
usedPct := 0.0
|
|
if ms.ullTotalPhys > 0 {
|
|
usedPct = float64(used) / float64(ms.ullTotalPhys) * 100.0
|
|
}
|
|
return MemoryInfo{
|
|
TotalBytes: ms.ullTotalPhys,
|
|
AvailableBytes: ms.ullAvailPhys,
|
|
UsedPercent: usedPct,
|
|
}
|
|
}
|
|
|
|
func getDiskInfo() []DiskInfo {
|
|
var disks []DiskInfo
|
|
|
|
// Alle logischen Laufwerke ermitteln
|
|
buf := make([]uint16, 256)
|
|
kernel32.NewProc("GetLogicalDriveStringsW").Call(
|
|
uintptr(len(buf)),
|
|
uintptr(unsafe.Pointer(&buf[0])),
|
|
)
|
|
|
|
// buf enthält null-separierte Strings ("C:\\\0D:\\\0\0")
|
|
drives := windows.UTF16ToString(buf)
|
|
for _, drive := range strings.Split(drives, "\x00") {
|
|
drive = strings.TrimSpace(drive)
|
|
if len(drive) < 2 {
|
|
continue
|
|
}
|
|
|
|
// Nur lokale Festplatten (DRIVE_FIXED = 3)
|
|
driveType := kernel32.NewProc("GetDriveTypeW")
|
|
drivePath := windows.StringToUTF16Ptr(drive)
|
|
t, _, _ := driveType.Call(uintptr(unsafe.Pointer(drivePath)))
|
|
if t != 3 { // DRIVE_FIXED
|
|
continue
|
|
}
|
|
|
|
// Speicherplatz
|
|
var freeBytesToCaller, totalBytes, freeBytes uint64
|
|
kernel32.NewProc("GetDiskFreeSpaceExW").Call(
|
|
uintptr(unsafe.Pointer(drivePath)),
|
|
uintptr(unsafe.Pointer(&freeBytesToCaller)),
|
|
uintptr(unsafe.Pointer(&totalBytes)),
|
|
uintptr(unsafe.Pointer(&freeBytes)),
|
|
)
|
|
|
|
if totalBytes == 0 {
|
|
continue
|
|
}
|
|
|
|
usedPct := float64(totalBytes-freeBytes) / float64(totalBytes) * 100.0
|
|
|
|
// Laufwerksbuchstabe ohne Backslash
|
|
letter := strings.TrimSuffix(strings.TrimSuffix(drive, `\`), "/")
|
|
|
|
disks = append(disks, DiskInfo{
|
|
Drive: letter,
|
|
TotalBytes: totalBytes,
|
|
FreeBytes: freeBytes,
|
|
UsedPercent: usedPct,
|
|
})
|
|
}
|
|
return disks
|
|
}
|
|
|
|
func getDomain() string {
|
|
out, err := runPS("(Get-WmiObject Win32_ComputerSystem).Domain")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
d := strings.TrimSpace(out)
|
|
if d == "WORKGROUP" {
|
|
return ""
|
|
}
|
|
return d
|
|
}
|
|
|
|
// ToJSON serialisiert SystemData für den Heartbeat
|
|
func (s *SystemData) ToJSON() (json.RawMessage, error) {
|
|
b, err := json.Marshal(s)
|
|
return json.RawMessage(b), err
|
|
}
|
|
|
|
// runPS führt einen PowerShell-Befehl aus und gibt stdout zurück
|
|
func runPS(cmd string) (string, error) {
|
|
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd).Output()
|
|
if err != nil {
|
|
log.Printf("PowerShell-Fehler (%s): %v", cmd[:min(len(cmd), 40)], err)
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// getInstalledSoftware liest installierte Software aus der Windows-Registry.
|
|
// Funktioniert auch unter SYSTEM-Account (kein winget noetig).
|
|
func getInstalledSoftware() []SoftwareInfo {
|
|
var result []SoftwareInfo
|
|
seen := make(map[string]bool)
|
|
|
|
regPaths := []struct {
|
|
root registry.Key
|
|
path string
|
|
}{
|
|
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|
{registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|
}
|
|
|
|
for _, rp := range regPaths {
|
|
k, err := registry.OpenKey(rp.root, rp.path, registry.READ|registry.ENUMERATE_SUB_KEYS)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
subkeys, err := k.ReadSubKeyNames(-1)
|
|
k.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, subkey := range subkeys {
|
|
sk, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name, _, _ := sk.GetStringValue("DisplayName")
|
|
sk.Close()
|
|
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
|
|
// Nochmal öffnen für weitere Felder (getrennt um Close sauber zu halten)
|
|
sk2, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
|
if err != nil {
|
|
result = append(result, SoftwareInfo{Name: name})
|
|
continue
|
|
}
|
|
version, _, _ := sk2.GetStringValue("DisplayVersion")
|
|
publisher, _, _ := sk2.GetStringValue("Publisher")
|
|
installDate, _, _ := sk2.GetStringValue("InstallDate")
|
|
|
|
// SystemComponent überspringen (Windows-interne Komponenten)
|
|
sysComp, _, _ := sk2.GetIntegerValue("SystemComponent")
|
|
sk2.Close()
|
|
if sysComp == 1 {
|
|
continue
|
|
}
|
|
|
|
result = append(result, SoftwareInfo{
|
|
Name: name,
|
|
Version: version,
|
|
Publisher: publisher,
|
|
InstallDate: installDate,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
|
})
|
|
return result
|
|
}
|