generated from sendnrw/template_golang
1176 lines
33 KiB
Go
1176 lines
33 KiB
Go
//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"
|
|
"syscall"
|
|
"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(`<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Windows Systeminfo</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f5f6f8;
|
|
--fg: #121826;
|
|
--muted: #5d6b82;
|
|
--card: #ffffff;
|
|
--accent: #0066ff;
|
|
--border: rgba(12, 21, 37, 0.08);
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{
|
|
max-width:1200px;
|
|
margin:0 auto;
|
|
padding:24px;
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;
|
|
background:var(--bg);
|
|
color:var(--fg);
|
|
}
|
|
h1{font-size:22px;margin:0 0 6px 0}
|
|
h2{margin:0 0 16px 0}
|
|
.grid{display:flex;flex-direction:column;gap:12px}
|
|
.card{
|
|
width:100%;
|
|
background:var(--card);
|
|
border:1px solid var(--border);
|
|
border-radius:16px;
|
|
padding:14px 16px 10px 16px;
|
|
box-shadow:0 10px 30px rgba(15,23,42,0.03);
|
|
}
|
|
.k{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
|
|
.v{font-weight:600}
|
|
.row{display:flex;justify-content:space-between;gap:8px;align-items:center;margin:6px 0}
|
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
|
|
.stereo{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color: #ff0000ff}
|
|
.pill{
|
|
display:inline-block;
|
|
background:#e8f1ff;
|
|
color:#0047cc;
|
|
border-radius:999px;
|
|
padding:2px 8px;
|
|
margin:2px 6px 0 0;
|
|
font-size:14px;
|
|
}
|
|
.pill2{
|
|
display:inline-block;
|
|
background:#ffe480;
|
|
color:#3e3e3e;
|
|
border-radius:999px;
|
|
padding:2px 8px;
|
|
margin:2px 6px 0 0;
|
|
font-size:14px;
|
|
}
|
|
.bar{
|
|
height:10px;
|
|
background:#edf0f5;
|
|
border-radius:999px;
|
|
overflow:hidden;
|
|
}
|
|
.fill{
|
|
height:100%;
|
|
background:var(--accent);
|
|
width:0%;
|
|
transition:width .3s ease-out;
|
|
}
|
|
footer{margin-top:18px;color:var(--muted);font-size:12px}
|
|
.disks table{width:100%;border-collapse:collapse}
|
|
.disks th,.disks td{
|
|
padding:8px;
|
|
border-bottom:1px solid #e5e9f2;
|
|
text-align:left;
|
|
font-size:14px
|
|
}
|
|
.disks th{background:#f5f6f8;font-weight:500}
|
|
button#btnLoadApps{
|
|
border:none;
|
|
background:#e8f1ff;
|
|
color:#0047cc;
|
|
font-weight:500;
|
|
}
|
|
input#appsFilter{
|
|
background:#ffffff;
|
|
color:var(--fg);
|
|
border:1px solid #d0d7e2;
|
|
border-radius:8px;
|
|
}
|
|
table thead th{font-size:13px;color:#3b4560}
|
|
@media (prefers-color-scheme: dark) {
|
|
/* optional: falls Host dunkles Theme erzwingt */
|
|
:root {
|
|
--bg:#f5f6f8;
|
|
--fg:#121826;
|
|
--card:#ffffff;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Windows Systeminfo</h1>
|
|
<h2><div class="v stereo" id="hostname2"></div></h2>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div class="k">Host</div>
|
|
<div class="row"><div>Hostname</div><div class="v stereo" id="hostname"></div></div>
|
|
<div class="row"><div>User</div><div class="v mono" id="username"></div></div>
|
|
<div class="row"><div>Uptime</div><div class="v" id="uptime"></div></div>
|
|
<div class="row"><div>Boot</div><div class="v" id="boottime"></div></div>
|
|
<div class="row"><div>Arch</div><div class="v mono" id="arch"></div></div>
|
|
|
|
<div class="row" style="margin-top:8px;">
|
|
<div>Aktionen</div>
|
|
<div>
|
|
<button id="btnGpupdate" class="pill2" style="cursor:pointer;">gpupdate /force</button>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div></div>
|
|
<div class="mono" id="gpupdateResult" style="font-size:12px;color:var(--muted);white-space:pre-wrap;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="k">OS</div>
|
|
<div class="row"><div>Betriebssystem</div><div class="v" id="os"></div></div>
|
|
<div class="row"><div>Platform</div><div class="v" id="platform"></div></div>
|
|
<div class="row"><div>Kernel</div><div class="v mono" id="kernel"></div></div>
|
|
<div class="row"><div>Stand</div><div class="v" id="ts"></div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="k">CPU</div>
|
|
<div class="row"><div>Modell</div><div class="v" id="cpu_model"></div></div>
|
|
<div class="row"><div>Kerne (phys/log)</div><div class="v" id="cores"></div></div>
|
|
<div class="row"><div>Auslastung</div><div class="v" id="cpu_pct"></div></div>
|
|
<div class="bar"><div class="fill" id="cpu_bar"></div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="k">Speicher</div>
|
|
<div class="row"><div>RAM</div><div class="v" id="ram"></div></div>
|
|
<div class="bar"><div class="fill" id="ram_bar"></div></div>
|
|
<div class="row" style="margin-top:8px;"><div>Swap</div><div class="v" id="swap"></div></div>
|
|
<div class="bar"><div class="fill" id="swap_bar"></div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="k">Netzwerk</div>
|
|
<div id="ifaces"></div>
|
|
</div>
|
|
|
|
<div class="card disks">
|
|
<div class="k">Laufwerke</div>
|
|
<table>
|
|
<thead><tr><th>Mount</th><th>Typ</th><th>Belegt</th><th>Gesamt</th><th>%</th></tr></thead>
|
|
<tbody id="disks"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="card">
|
|
<div class="k">Installierte Programme</div>
|
|
<div class="row" style="gap:8px;align-items:center;">
|
|
<button id="btnLoadApps" class="pill" style="cursor:pointer;">Laden</button>
|
|
<input id="appsFilter" type="text" placeholder="Suchen …"
|
|
style="flex:1;padding:6px 10px;">
|
|
<span class="k" id="appsCount"></span>
|
|
</div>
|
|
<div class="apps">
|
|
<table style="width:100%;border-collapse:collapse">
|
|
<thead>
|
|
<tr>
|
|
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Name</th>
|
|
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Version</th>
|
|
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Publisher</th>
|
|
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Quelle</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="apps"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>Aktualisiert alle 2s</footer>
|
|
|
|
<script>
|
|
function bytes(n){ if(n===0||n===undefined) return "0 B"; const u=['B','KB','MB','GB','TB','PB']; let i=Math.floor(Math.log(n)/Math.log(1024)); i=Math.min(i,u.length-1); return (n/Math.pow(1024,i)).toFixed(1)+" "+u[i]; }
|
|
function pct(p){ return (p||0).toFixed(1)+"%"; }
|
|
function hms(sec){ sec = Math.floor(sec||0); const d=Math.floor(sec/86400); sec%=86400; const h=Math.floor(sec/3600); sec%=3600; const m=Math.floor(sec/60); const s=sec%60; let out=[]; if(d) out.push(d+"d"); if(h) out.push(h+"h"); if(m) out.push(m+"m"); out.push(s+"s"); return out.join(" "); }
|
|
|
|
async function load(){
|
|
try{
|
|
const r = await fetch('/api/summary');
|
|
const j = await r.json();
|
|
|
|
document.getElementById('hostname').textContent = j.hostname || '-';
|
|
document.getElementById('hostname2').textContent = j.hostname || '-';
|
|
document.getElementById('username').textContent = j.username || '-';
|
|
document.getElementById('uptime').textContent = hms(j.uptime_seconds);
|
|
document.getElementById('boottime').textContent = j.boot_time ? new Date(j.boot_time).toLocaleString() : '-';
|
|
document.getElementById('arch').textContent = j.arch || '-';
|
|
|
|
document.getElementById('os').textContent = (j.os||'') ;
|
|
document.getElementById('platform').textContent = (j.platform||'') + (j.platform_version?(" "+j.platform_version):"");
|
|
document.getElementById('kernel').textContent = j.kernel_version || '-';
|
|
document.getElementById('ts').textContent = j.timestamp ? new Date(j.timestamp).toLocaleString() : '-';
|
|
|
|
document.getElementById('cpu_model').textContent= j.cpu_model || '-';
|
|
document.getElementById('cores').textContent = (j.physical_cores||0) + " / " + (j.logical_cores||0);
|
|
document.getElementById('cpu_pct').textContent = pct(j.cpu_percent);
|
|
document.getElementById('cpu_bar').style.width = (j.cpu_percent||0)+"%";
|
|
|
|
document.getElementById('ram').textContent = bytes(j.memory_used)+" / "+bytes(j.memory_total)+" ("+pct(j.memory_used_percent)+")";
|
|
document.getElementById('ram_bar').style.width = (j.memory_used_percent||0)+"%";
|
|
|
|
document.getElementById('swap').textContent = bytes(j.swap_used)+" / "+bytes(j.swap_total)+" ("+pct(j.swap_used_percent)+")";
|
|
document.getElementById('swap_bar').style.width = (j.swap_used_percent||0)+"%";
|
|
|
|
const wrap = document.getElementById('ifaces'); wrap.innerHTML='';
|
|
(j.interfaces||[]).forEach(n=>{
|
|
const div = document.createElement('div');
|
|
div.innerHTML = '<div class="row"><div>'+n.name+'</div><div class="mono">'+(n.mac||'-')+'</div></div>';
|
|
if (n.profile) {
|
|
const p = document.createElement('span');
|
|
p.className = 'pill2';
|
|
p.textContent = n.profile;
|
|
div.appendChild(p);
|
|
}
|
|
(n.addresses||[]).forEach(ip=>{ const span=document.createElement('span'); span.className='pill mono'; span.textContent=ip; div.appendChild(span); });
|
|
wrap.appendChild(div);
|
|
});
|
|
|
|
const tbody = document.getElementById('disks'); tbody.innerHTML='';
|
|
(j.disks||[]).forEach(d=>{
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = '<td class="mono">'+d.mountpoint+'</td><td>'+d.fstype+'</td><td>'+bytes(d.used)+'</td><td>'+bytes(d.total)+'</td><td>'+pct(d.used_percent)+'</td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
}catch(e){ console.error(e); }
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 2000);
|
|
|
|
let appsCache = null;
|
|
|
|
function renderApps(list){
|
|
const tbody = document.getElementById('apps');
|
|
tbody.innerHTML = '';
|
|
(list||[]).forEach(a=>{
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML =
|
|
'<td class="mono" style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.name||'-')+'</td>'+
|
|
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.version||'')+'</td>'+
|
|
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.publisher||'')+'</td>'+
|
|
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.source||'')+'</td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
const c = document.getElementById('appsCount');
|
|
if(c) c.textContent = (list||[]).length+' Einträge';
|
|
}
|
|
|
|
async function loadAppsOnce(){
|
|
if(appsCache) return;
|
|
try{
|
|
const r = await fetch('/api/apps');
|
|
appsCache = await r.json();
|
|
renderApps(appsCache);
|
|
}catch(e){
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', ()=>{
|
|
const btn = document.getElementById('btnLoadApps');
|
|
const inp = document.getElementById('appsFilter');
|
|
if(btn){ btn.addEventListener('click', loadAppsOnce); }
|
|
if(inp){
|
|
inp.addEventListener('input', ()=>{
|
|
if(!appsCache) return;
|
|
const q = inp.value.toLowerCase();
|
|
const filtered = appsCache.filter(a =>
|
|
(a.name||'').toLowerCase().includes(q) ||
|
|
(a.version||'').toLowerCase().includes(q) ||
|
|
(a.publisher||'').toLowerCase().includes(q) ||
|
|
(a.source||'').toLowerCase().includes(q)
|
|
);
|
|
renderApps(filtered);
|
|
});
|
|
}
|
|
|
|
const btnGp = document.getElementById('btnGpupdate');
|
|
const resGp = document.getElementById('gpupdateResult');
|
|
if (btnGp && resGp) {
|
|
btnGp.addEventListener('click', async ()=>{
|
|
resGp.textContent = 'gpupdate /force wird gestartet …';
|
|
try {
|
|
const r = await fetch('/api/gpupdate', { method: 'POST' });
|
|
if (!r.ok) {
|
|
resGp.textContent = 'Fehler: HTTP ' + r.status + ' ' + r.statusText;
|
|
return;
|
|
}
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
resGp.textContent = 'gpupdate /force ausgeführt (ExitCode ' + (j.exit_code ?? 0) + ').';
|
|
} else {
|
|
resGp.textContent = 'Fehler bei gpupdate (ExitCode ' + (j.exit_code ?? -1) + '): ' + (j.error || '');
|
|
}
|
|
} catch (e) {
|
|
resGp.textContent = 'Request-Fehler: ' + e;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`))
|
|
|
|
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/gpupdate", gpupdateHandler)
|
|
|
|
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
|
|
}
|
|
|
|
type GPUpdateResponse struct {
|
|
OK bool `json:"ok"`
|
|
ExitCode int `json:"exit_code"`
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func gpupdateHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// nur lokale Aufrufe zulassen
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
if host != "127.0.0.1" && host != "::1" {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "cmd", "/C", "gpupdate", "/force")
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
resp := GPUpdateResponse{
|
|
Stdout: string(out),
|
|
Stderr: "",
|
|
OK: true,
|
|
}
|
|
|
|
// Timeout?
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
resp.OK = false
|
|
resp.Error = "gpupdate /force: Timeout (2 Minuten)"
|
|
}
|
|
|
|
// Exitcode auswerten
|
|
if err != nil {
|
|
resp.OK = false
|
|
resp.Error = err.Error()
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
if status, ok2 := exitErr.Sys().(syscall.WaitStatus); ok2 {
|
|
resp.ExitCode = status.ExitStatus()
|
|
}
|
|
}
|
|
} else if cmd.ProcessState != nil {
|
|
if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok {
|
|
resp.ExitCode = status.ExitStatus()
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|