cynfo3000 bf51ed8569 feat: WAU-Integration + Windows Software-Inventar (v1.1.0)
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)
2026-03-10 08:46:00 +01:00

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
}