//go:build windows package main import ( "context" "encoding/json" "html/template" "log" "net" "net/http" "os" "os/exec" "os/user" "runtime" "sort" "strconv" "strings" "sync" "time" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" "golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/svc" ) type NetInterface struct { Name string `json:"name"` MAC string `json:"mac"` Addresses []string `json:"addresses"` IsLoopback bool `json:"is_loopback"` Profile string `json:"profile"` } type DiskInfo struct { Device string `json:"device"` Mountpoint string `json:"mountpoint"` Fstype string `json:"fstype"` Total uint64 `json:"total"` Used uint64 `json:"used"` Free uint64 `json:"free"` UsedPercent float64 `json:"used_percent"` ReadOnly bool `json:"read_only"` } type Summary struct { Timestamp time.Time `json:"timestamp"` Hostname string `json:"hostname"` Username string `json:"username"` OS string `json:"os"` Platform string `json:"platform"` PlatformVersion string `json:"platform_version"` KernelVersion string `json:"kernel_version"` Arch string `json:"arch"` UptimeSeconds uint64 `json:"uptime_seconds"` BootTime time.Time `json:"boot_time"` Interfaces []NetInterface `json:"interfaces"` CPUModel string `json:"cpu_model"` PhysicalCores int `json:"physical_cores"` LogicalCores int `json:"logical_cores"` CPUPercent float64 `json:"cpu_percent"` MemoryTotal uint64 `json:"memory_total"` MemoryUsed uint64 `json:"memory_used"` MemoryFree uint64 `json:"memory_free"` MemoryUsedPct float64 `json:"memory_used_percent"` SwapTotal uint64 `json:"swap_total"` SwapUsed uint64 `json:"swap_used"` SwapFree uint64 `json:"swap_free"` SwapUsedPct float64 `json:"swap_used_percent"` Disks []DiskInfo `json:"disks"` } type InstalledApp struct { Name string `json:"name"` Version string `json:"version"` Publisher string `json:"publisher"` InstallDate string `json:"install_date"` Source string `json:"source"` // HKLM-64, HKLM-32, HKCU } type DeviceInfo struct { InstanceID string `json:"instance_id"` Class string `json:"class"` ClassGUID string `json:"class_guid"` FriendlyName string `json:"friendly_name"` Manufacturer string `json:"manufacturer"` Status string `json:"status"` Present bool `json:"present"` } type Notification struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at"` Title string `json:"title"` Message string `json:"message"` TargetUser string `json:"target_user"` // "" = Broadcast an alle User } var ( notifyMu sync.Mutex notifySeq int64 notifyRing []Notification ) // maximale Anzahl vorgehaltener Nachrichten (FIFO-Ring) const maxNotifications = 1000 var ( mu sync.RWMutex lastCPUUsage float64 ) func addNotification(n Notification) Notification { notifyMu.Lock() defer notifyMu.Unlock() notifySeq++ n.ID = notifySeq if n.CreatedAt.IsZero() { n.CreatedAt = time.Now() } notifyRing = append(notifyRing, n) if len(notifyRing) > maxNotifications { // älteste abschneiden notifyRing = notifyRing[len(notifyRing)-maxNotifications:] } return n } // userName: z.B. "DOMAIN\\user" oder "user" // sinceID: letzte gesehene ID des Agents func getNotificationsForUser(userName string, sinceID int64) []Notification { notifyMu.Lock() defer notifyMu.Unlock() userNameLower := strings.ToLower(strings.TrimSpace(userName)) var res []Notification for _, n := range notifyRing { if n.ID <= sinceID { continue } // Broadcast if strings.TrimSpace(n.TargetUser) == "" { res = append(res, n) continue } // Ziel-User matchen (case-insensitive) if strings.EqualFold(n.TargetUser, userName) || strings.EqualFold(n.TargetUser, userNameLower) { res = append(res, n) } } return res } type NotifyRequest struct { Title string `json:"title"` Message string `json:"message"` TargetUser string `json:"target_user"` // optional, "" = an alle } func notifyFromServerHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // optional: Authentifizierung per Token token := os.Getenv("NOTIFY_TOKEN") if token != "" && r.Header.Get("X-Notify-Token") != token { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req NotifyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } req.Title = strings.TrimSpace(req.Title) req.Message = strings.TrimSpace(req.Message) req.TargetUser = strings.TrimSpace(req.TargetUser) if req.Message == "" { http.Error(w, "message must not be empty", http.StatusBadRequest) return } if req.Title == "" { req.Title = "Benachrichtigung" } n := addNotification(Notification{ Title: req.Title, Message: req.Message, TargetUser: req.TargetUser, }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(n) } func notificationsForAgentHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } userName := strings.TrimSpace(r.URL.Query().Get("user")) if userName == "" { http.Error(w, "missing 'user' query parameter", http.StatusBadRequest) return } sinceStr := r.URL.Query().Get("since_id") var sinceID int64 if sinceStr != "" { if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil { sinceID = v } } // optional: Zugriff auf localhost beschränken host, _, _ := net.SplitHostPort(r.RemoteAddr) if host != "127.0.0.1" && host != "::1" { http.Error(w, "forbidden", http.StatusForbidden) return } notifs := getNotificationsForUser(userName, sinceID) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(notifs) } func getPnpDevices() ([]DeviceInfo, error) { // PowerShell: aktuelle PnP-Geräte, inkl. ClassGuid cmd := exec.Command( "powershell", "-NoProfile", "-NonInteractive", "-Command", `Get-PnpDevice -PresentOnly | Select-Object InstanceId,Class,ClassGuid,FriendlyName,Manufacturer,Status,Present | ConvertTo-Json -Depth 3`, ) out, err := cmd.Output() if err != nil { log.Printf("getPnpDevices: powershell error: %v", err) return nil, err } if len(out) == 0 { return []DeviceInfo{}, nil } // PS gibt entweder ein Objekt oder ein Array zurück → wie bei getWinNetProfiles behandeln type psDevice struct { InstanceId string `json:"InstanceId"` Class string `json:"Class"` ClassGuid string `json:"ClassGuid"` FriendlyName string `json:"FriendlyName"` Manufacturer string `json:"Manufacturer"` Status string `json:"Status"` Present interface{} `json:"Present"` } parsePresent := func(v interface{}) bool { switch t := v.(type) { case bool: return t case string: // "True"/"False" etc. return strings.EqualFold(t, "true") default: return false } } var arr []psDevice if err := json.Unmarshal(out, &arr); err == nil { res := make([]DeviceInfo, 0, len(arr)) for _, d := range arr { res = append(res, DeviceInfo{ InstanceID: d.InstanceId, Class: d.Class, ClassGUID: d.ClassGuid, FriendlyName: d.FriendlyName, Manufacturer: d.Manufacturer, Status: d.Status, Present: parsePresent(d.Present), }) } return res, nil } // Ein einzelnes Objekt var single psDevice if err := json.Unmarshal(out, &single); err != nil { log.Printf("getPnpDevices: cannot unmarshal: %v -- out: %s", err, string(out)) return nil, err } return []DeviceInfo{ { InstanceID: single.InstanceId, Class: single.Class, ClassGUID: single.ClassGuid, FriendlyName: single.FriendlyName, Manufacturer: single.Manufacturer, Status: single.Status, Present: parsePresent(single.Present), }, }, nil } func devicesHandler(w http.ResponseWriter, r *http.Request) { devs, err := getPnpDevices() if err != nil { http.Error(w, "failed to enumerate devices: "+err.Error(), http.StatusInternalServerError) return } // Optional: Filter nur USB-Geräte (InstanceID beginnt mit "USB\") if r.URL.Query().Get("only_usb") == "1" { filtered := make([]DeviceInfo, 0, len(devs)) for _, d := range devs { if strings.HasPrefix(strings.ToUpper(d.InstanceID), "USB\\") { filtered = append(filtered, d) } } devs = filtered } w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(devs) } func readUninstallKey(base registry.Key, path, src string) []InstalledApp { k, err := registry.OpenKey(base, path, registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) if err != nil { return nil } defer k.Close() names, err := k.ReadSubKeyNames(-1) if err != nil { return nil } apps := make([]InstalledApp, 0, len(names)) for _, name := range names { sk, err := registry.OpenKey(k, name, registry.QUERY_VALUE) if err != nil { continue } // versteckte System-Komponenten ausblenden if v, _, err := sk.GetIntegerValue("SystemComponent"); err == nil && v == 1 { sk.Close() continue } displayName, _, _ := sk.GetStringValue("DisplayName") if strings.TrimSpace(displayName) == "" { sk.Close() continue } displayVersion, _, _ := sk.GetStringValue("DisplayVersion") publisher, _, _ := sk.GetStringValue("Publisher") installDate, _, _ := sk.GetStringValue("InstallDate") apps = append(apps, InstalledApp{ Name: displayName, Version: displayVersion, Publisher: publisher, InstallDate: installDate, Source: src, }) sk.Close() } return apps } func getInstalledApps() []InstalledApp { var all []InstalledApp // Maschinenweit 64-bit all = append(all, readUninstallKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM-64")..., ) // Maschinenweit 32-bit auf 64-bit all = append(all, readUninstallKey(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM-32")..., ) // Aktueller Benutzer all = append(all, readUninstallKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKCU")..., ) // Deduplizieren (Name+Version+Publisher) uniq := make(map[string]InstalledApp, len(all)) for _, a := range all { key := strings.ToLower(strings.TrimSpace(a.Name) + "|" + strings.TrimSpace(a.Version) + "|" + strings.TrimSpace(a.Publisher)) if _, ok := uniq[key]; !ok { uniq[key] = a } } out := make([]InstalledApp, 0, len(uniq)) for _, v := range uniq { out = append(out, v) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } func appsHandler(w http.ResponseWriter, r *http.Request) { apps := getInstalledApps() w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(apps) } // background sampler for CPU percentage to avoid blocking API calls func startCPUSampler(ctx context.Context) { go func() { // Prime once _, _ = cpu.Percent(0, false) for { select { case <-ctx.Done(): return default: } pcts, err := cpu.Percent(time.Second, false) if err == nil && len(pcts) > 0 { mu.Lock() lastCPUUsage = pcts[0] mu.Unlock() } } }() } func getInterfaces() []NetInterface { ifaces, err := net.Interfaces() if err != nil { return nil } // Profile holen (best effort) profilesByIndex, _ := getWinNetProfiles() var out []NetInterface for _, ifc := range ifaces { if (ifc.Flags & net.FlagUp) == 0 { continue } addrs, _ := ifc.Addrs() var ips []string isLoop := (ifc.Flags & net.FlagLoopback) != 0 for _, a := range addrs { var ip net.IP switch v := a.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP } if ip == nil || ip.IsLoopback() { continue } ips = append(ips, ip.String()) } if isLoop { continue } profile := profilesByIndex[ifc.Index] // 👈 jetzt sicher über Index out = append(out, NetInterface{ Name: ifc.Name, MAC: ifc.HardwareAddr.String(), Addresses: ips, IsLoopback: isLoop, Profile: profile, }) } return out } func getDisks() []DiskInfo { parts, err := disk.Partitions(true) if err != nil { return nil } var out []DiskInfo for _, p := range parts { du, err := disk.Usage(p.Mountpoint) if err != nil { continue } ro := false switch opts := any(p.Opts).(type) { case string: ro = strings.Contains(strings.ToLower(opts), "ro") case []string: for _, o := range opts { if strings.EqualFold(o, "ro") { ro = true break } } } out = append(out, DiskInfo{ Device: p.Device, Mountpoint: p.Mountpoint, Fstype: p.Fstype, Total: du.Total, Used: du.Used, Free: du.Free, UsedPercent: du.UsedPercent, ReadOnly: ro, }) } return out } func collectSummary() Summary { hi, _ := host.Info() vm, _ := mem.VirtualMemory() sw, _ := mem.SwapMemory() coresPhys, _ := cpu.Counts(false) coresLog, _ := cpu.Counts(true) cpuInfos, _ := cpu.Info() username := "" if u, err := user.Current(); err == nil { username = u.Username } else { username = os.Getenv("USERNAME") } model := "" if len(cpuInfos) > 0 { model = cpuInfos[0].ModelName } mu.RLock() cpuPct := lastCPUUsage mu.RUnlock() var boot time.Time if hi != nil && hi.BootTime > 0 { boot = time.Unix(int64(hi.BootTime), 0) } return Summary{ Timestamp: time.Now(), Hostname: safe(hi, func(h *host.InfoStat) string { return h.Hostname }), Username: username, OS: safe(hi, func(h *host.InfoStat) string { return h.OS }), Platform: safe(hi, func(h *host.InfoStat) string { return h.Platform }), PlatformVersion: safe(hi, func(h *host.InfoStat) string { return h.PlatformVersion }), KernelVersion: safe(hi, func(h *host.InfoStat) string { return h.KernelVersion }), Arch: runtime.GOARCH, UptimeSeconds: safe(hi, func(h *host.InfoStat) uint64 { return h.Uptime }), BootTime: boot, Interfaces: getInterfaces(), CPUModel: model, PhysicalCores: coresPhys, LogicalCores: coresLog, CPUPercent: cpuPct, MemoryTotal: vm.Total, MemoryUsed: vm.Used, MemoryFree: vm.Free, MemoryUsedPct: vm.UsedPercent, SwapTotal: sw.Total, SwapUsed: sw.Used, SwapFree: sw.Free, SwapUsedPct: sw.UsedPercent, Disks: getDisks(), } } func safe[T any, R any](v T, f func(T) R) R { if any(v) == nil { var zero R return zero } return f(v) } var page = template.Must(template.New("index").Parse(` Windows Systeminfo

Windows Systeminfo

Host
Hostname
User
Uptime
Boot
Arch
OS
Betriebssystem
Platform
Kernel
Stand
CPU
Modell
Kerne (phys/log)
Auslastung
Speicher
RAM
Swap
Netzwerk
Laufwerke
MountTypBelegtGesamt%
Installierte Programme
Name Version Publisher Quelle
`)) func indexHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := page.Execute(w, nil); err != nil { http.Error(w, err.Error(), 500) } } func apiHandler(w http.ResponseWriter, r *http.Request) { sum := collectSummary() w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(sum); err != nil { http.Error(w, err.Error(), 500) } } // --- ab hier: Dienst-Integration --- const serviceName = "GoSysInfoService" // runHTTP startet deinen bisherigen HTTP-Server und reagiert auf ctx.Done() func runHTTP(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/", indexHandler) mux.HandleFunc("/api/apps", appsHandler) mux.HandleFunc("/api/summary", apiHandler) mux.HandleFunc("/api/devices", devicesHandler) mux.HandleFunc("/api/notify", notifyFromServerHandler) mux.HandleFunc("/api/notifications", notificationsForAgentHandler) addr := ":24000" srv := &http.Server{ Addr: addr, Handler: mux, } // CPU-Sampler an das gleiche ctx hängen startCPUSampler(ctx) log.Printf("Starte lokales Dashboard auf http://%s … (nur von diesem Rechner erreichbar)", addr) // Shutdown-Goroutine go func() { <-ctx.Done() shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _ = srv.Shutdown(shCtx) cancel() // explizit aufrufen, kein defer in der Goroutine nötig }() // Blockiert hier bis Stop oder Fehler err := srv.ListenAndServe() // ListenAndServe gibt bei Shutdown typischerweise http.ErrServerClosed zurück if err == http.ErrServerClosed { return nil } return err } // windows-svc Wrapper type winService struct{} func (s *winService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { const accepted = svc.AcceptStop | svc.AcceptShutdown status <- svc.Status{State: svc.StartPending} ctx, cancel := context.WithCancel(context.Background()) defer cancel() // <-- neu: für alle Rückgabepfade go func() { if err := runHTTP(ctx); err != nil { log.Printf("HTTP server stopped: %v", err) } }() status <- svc.Status{State: svc.Running, Accepts: accepted} for c := range r { switch c.Cmd { case svc.Interrogate: status <- c.CurrentStatus case svc.Stop, svc.Shutdown: status <- svc.Status{State: svc.StopPending} return false, 0 // cancel() läuft trotzdem dank defer } } // falls der Channel mal zu ist return false, 0 } func main() { // Prüfen, ob wir als Windows-Dienst laufen isService, err := svc.IsWindowsService() if err != nil { log.Fatalf("svc.IsWindowsService: %v", err) } if isService { // Hier spricht das Programm mit dem Service Control Manager if err := svc.Run(serviceName, &winService{}); err != nil { log.Fatalf("svc.Run failed: %v", err) } return } // normaler Konsolenmodus ctx := context.Background() if err := runHTTP(ctx); err != nil { log.Fatal(err) } } type psConnProfile struct { Name string `json:"Name"` InterfaceAlias string `json:"InterfaceAlias"` InterfaceIndex int `json:"InterfaceIndex"` NetworkCategory string `json:"NetworkCategory"` // "Public", "Private", "DomainAuthenticated" } func getWinNetProfiles() (map[int]string, error) { cmd := exec.Command( "powershell", "-NoProfile", "-NonInteractive", "-Command", `Get-NetConnectionProfile | Select-Object InterfaceAlias,InterfaceIndex,NetworkCategory | ConvertTo-Json -Depth 3`, ) out, err := cmd.Output() if err != nil { log.Printf("getWinNetProfiles: powershell error: %v", err) return nil, err } if len(out) == 0 { return map[int]string{}, nil } // Wir wissen nicht, ob PS ein Objekt oder ein Array zurückgibt → erst Array versuchen type psConnProfile struct { InterfaceAlias string `json:"InterfaceAlias"` InterfaceIndex int `json:"InterfaceIndex"` NetworkCategory interface{} `json:"NetworkCategory"` } normalizeCat := func(v interface{}) string { switch vv := v.(type) { case string: // "Public", "Private", "DomainAuthenticated" return vv case float64: switch int(vv) { case 0: return "Public" case 1: return "Private" case 2: return "DomainAuthenticated" default: return "Unknown" } default: return "" } } // 1) Array probieren var arr []psConnProfile if err := json.Unmarshal(out, &arr); err == nil { res := make(map[int]string, len(arr)) for _, p := range arr { res[p.InterfaceIndex] = normalizeCat(p.NetworkCategory) } return res, nil } // 2) Einzelnes Objekt probieren var single psConnProfile if err := json.Unmarshal(out, &single); err != nil { log.Printf("getWinNetProfiles: cannot unmarshal: %v -- out: %s", err, string(out)) return nil, err } res := make(map[int]string, 1) res[single.InterfaceIndex] = normalizeCat(single.NetworkCategory) return res, nil }