diff --git a/.gitignore b/.gitignore
index 8dfaeda..500279c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,8 +34,8 @@ updater/rmm-updater
frontend/node_modules/
frontend/dist/
-# config.js hat echte IPs und API-Keys — nie einchecken
-frontend/src/config.js
+# .env hat echte IPs und API-Keys — nie einchecken
+frontend/.env
!frontend/src/config.example.js
# ========================
diff --git a/agent-windows/collector/system.go b/agent-windows/collector/system.go
index 170897f..42d66ea 100644
--- a/agent-windows/collector/system.go
+++ b/agent-windows/collector/system.go
@@ -31,6 +31,7 @@ type WindowsData struct {
Disks []DiskInfo `json:"disks"`
Services []SvcInfo `json:"services,omitempty"`
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
+ WAU *WAUInfo `json:"wau,omitempty"`
Domain string `json:"domain,omitempty"`
LastUpdate time.Time `json:"last_update"`
}
@@ -123,6 +124,10 @@ func Collect() (*SystemData, error) {
// Installierte Software (Registry)
wd.InstalledSoftware = getInstalledSoftware()
+ // WAU-Status (Installation + Konfiguration + Pending Updates)
+ wauInfo := getWAUInfo(wd.InstalledSoftware)
+ wd.WAU = &wauInfo
+
return &SystemData{Windows: wd}, nil
}
diff --git a/agent-windows/collector/wau.go b/agent-windows/collector/wau.go
new file mode 100644
index 0000000..9fa0c3e
--- /dev/null
+++ b/agent-windows/collector/wau.go
@@ -0,0 +1,100 @@
+//go:build windows
+
+package collector
+
+import (
+ "os/exec"
+ "strings"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+// WAUInfo enthält den Status von Winget-AutoUpdate auf diesem Gerät
+type WAUInfo struct {
+ Installed bool `json:"installed"`
+ Version string `json:"version,omitempty"`
+ IncludeURL string `json:"include_url,omitempty"`
+ ExcludeURL string `json:"exclude_url,omitempty"`
+ PendingCount int `json:"pending_updates"`
+}
+
+// getWAUInfo liest WAU-Installations- und Konfigurationsstatus
+func getWAUInfo(software []SoftwareInfo) WAUInfo {
+ info := WAUInfo{}
+
+ // WAU in installierter Software suchen
+ for _, s := range software {
+ lower := strings.ToLower(s.Name)
+ if strings.Contains(lower, "winget-autoupdate") || lower == "wau" {
+ info.Installed = true
+ info.Version = s.Version
+ break
+ }
+ }
+
+ // WAU-Konfiguration aus Registry lesen
+ // WAU speichert seine Einstellungen unter HKLM\SOFTWARE\Winget-AutoUpdate
+ regPaths := []string{
+ `SOFTWARE\Winget-AutoUpdate`,
+ `SOFTWARE\Policies\Microsoft\Windows\Winget-AutoUpdate`,
+ }
+ for _, path := range regPaths {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
+ if err != nil {
+ continue
+ }
+ if url, _, err := k.GetStringValue("WAU_IncludeListURL"); err == nil && url != "" {
+ info.IncludeURL = url
+ }
+ if url, _, err := k.GetStringValue("WAU_ExcludeListURL"); err == nil && url != "" {
+ info.ExcludeURL = url
+ }
+ k.Close()
+ if info.IncludeURL != "" || info.ExcludeURL != "" {
+ info.Installed = true
+ break
+ }
+ }
+
+ // Pending updates via winget upgrade --list zählen
+ wp := findWingetExe()
+ if wp != "" {
+ out, err := runPS(`& "` + wp + `" upgrade --list --accept-source-agreements 2>&1`)
+ if err == nil {
+ info.PendingCount = countWingetUpgradeLines(out)
+ }
+ }
+
+ return info
+}
+
+// findWingetExe sucht winget.exe im System
+func findWingetExe() string {
+ if path, err := exec.LookPath("winget"); err == nil {
+ return path
+ }
+ script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
+ out, err := runPS(script)
+ if err == nil && strings.TrimSpace(out) != "" {
+ return strings.TrimSpace(out)
+ }
+ return ""
+}
+
+// countWingetUpgradeLines zählt Pakete mit Updates aus "winget upgrade --list" Output
+func countWingetUpgradeLines(output string) int {
+ lines := strings.Split(output, "\n")
+ count := 0
+ pastSeparator := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.Contains(trimmed, "---") {
+ pastSeparator = true
+ continue
+ }
+ if pastSeparator && trimmed != "" {
+ count++
+ }
+ }
+ return count
+}
diff --git a/agent-windows/main.go b/agent-windows/main.go
index 4eca756..9e161a6 100644
--- a/agent-windows/main.go
+++ b/agent-windows/main.go
@@ -27,7 +27,7 @@ import (
"github.com/cynfo/rmm-agent-windows/ws"
)
-var Version = "1.1.0"
+var Version = "1.2.0"
const (
ServiceName = "RMMAgent"
diff --git a/backend/api/wau.go b/backend/api/wau.go
index 8c53039..e4b8dbd 100644
--- a/backend/api/wau.go
+++ b/backend/api/wau.go
@@ -10,10 +10,14 @@ import (
// setupWAURoutes registriert alle WAU-Endpunkte
func (h *Handler) setupWAURoutes(mux *http.ServeMux) {
// Authentifizierte Endpunkte (API-Key oder JWT)
- mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
- mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
+ mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
+ mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
mux.HandleFunc("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
- mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
+ mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
+ mux.HandleFunc("GET /api/v1/customers/{id}/wau/compliance", h.getWAUCompliance)
+
+ // Compliance-Summary für alle Kunden
+ mux.HandleFunc("GET /api/v1/wau/compliance/summary", h.getWAUComplianceSummary)
// Plaintext-Endpunkte für WAU (kein Auth nötig — enthält nur App-IDs)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/included", h.wauIncludedList)
@@ -149,3 +153,51 @@ func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType
w.Write([]byte("\r\n"))
}
}
+
+// wauExpectedURLs leitet die erwarteten WAU-URLs aus dem Request-Host ab
+func wauExpectedURLs(r *http.Request, customerID int) (includeURL, excludeURL string) {
+ scheme := "https"
+ if r.TLS == nil && (r.Header.Get("X-Forwarded-Proto") == "" || r.Header.Get("X-Forwarded-Proto") == "http") {
+ scheme = "http"
+ }
+ if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
+ scheme = fwd
+ }
+ host := r.Host
+ base := scheme + "://" + host
+ includeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/included"
+ excludeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/excluded"
+ return
+}
+
+// GET /api/v1/customers/{id}/wau/compliance
+func (h *Handler) getWAUCompliance(w http.ResponseWriter, r *http.Request) {
+ customerID, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
+ return
+ }
+ includeURL, excludeURL := wauExpectedURLs(r, customerID)
+ agents, err := h.db.GetWAUCompliance(customerID, includeURL, excludeURL)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "agents": agents,
+ "include_url": includeURL,
+ "exclude_url": excludeURL,
+ })
+}
+
+// GET /api/v1/wau/compliance/summary — Zusammenfassung für alle Kunden
+func (h *Handler) getWAUComplianceSummary(w http.ResponseWriter, r *http.Request) {
+ // Ohne customer_id können wir keine URLs ableiten — wir geben Summary ohne URL-Check zurück
+ // oder nutzen customer_id=0 als Wildcard (kein URL-Check)
+ summaries, err := h.db.GetAllCustomersWAUSummary("", "")
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
+ return
+ }
+ writeJSON(w, http.StatusOK, summaries)
+}
diff --git a/backend/db/wau.go b/backend/db/wau.go
index b78880b..3ffefaf 100644
--- a/backend/db/wau.go
+++ b/backend/db/wau.go
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"encoding/json"
+ "strings"
"github.com/cynfo/rmm-backend/models"
)
@@ -133,3 +134,217 @@ func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftware
}
return entries, rows.Err()
}
+
+// GetWAUCompliance berechnet den Compliance-Status aller Windows-Agents eines Kunden
+// Prüft: WAU installiert, URLs korrekt, alle allow-Pakete installiert
+func (d *Database) GetWAUCompliance(customerID int, expectedIncludeURL, expectedExcludeURL string) ([]models.WAUAgentCompliance, error) {
+ // Alle Windows-Agents des Kunden mit ihren system_data holen
+ rows, err := d.db.Query(`
+ SELECT a.id, a.hostname, sd.data_json
+ FROM agents a
+ JOIN system_data sd ON sd.agent_id = a.id
+ WHERE a.customer_id = $1
+ AND sd.data_json ? 'windows'
+ ORDER BY lower(a.hostname)
+ `, customerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Allow-Pakete (= Required) für diesen Kunden laden
+ allowRules, err := d.GetWAUList(customerID, "allow")
+ if err != nil {
+ return nil, err
+ }
+ allowRulesFull, err := d.ListWAURules(customerID)
+ if err != nil {
+ return nil, err
+ }
+ // Map winget_id → display_name
+ displayNames := make(map[string]string)
+ for _, r := range allowRulesFull {
+ if r.RuleType == "allow" {
+ displayNames[r.WingetID] = r.DisplayName
+ }
+ }
+
+ var result []models.WAUAgentCompliance
+
+ for rows.Next() {
+ var agentID, hostname string
+ var dataJSON []byte
+ if err := rows.Scan(&agentID, &hostname, &dataJSON); err != nil {
+ continue
+ }
+
+ // JSON parsen (nur was wir brauchen)
+ var data struct {
+ Windows struct {
+ InstalledSoftware []struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ } `json:"installed_software"`
+ WAU *struct {
+ Installed bool `json:"installed"`
+ IncludeURL string `json:"include_url"`
+ ExcludeURL string `json:"exclude_url"`
+ PendingCount int `json:"pending_updates"`
+ } `json:"wau"`
+ } `json:"windows"`
+ }
+ if err := json.Unmarshal(dataJSON, &data); err != nil {
+ continue
+ }
+
+ ac := models.WAUAgentCompliance{
+ AgentID: agentID,
+ AgentName: hostname,
+ }
+
+ // WAU-Status
+ if data.Windows.WAU != nil {
+ wau := data.Windows.WAU
+ ac.WAUInstalled = wau.Installed
+ ac.IncludeURL = wau.IncludeURL
+ ac.ExcludeURL = wau.ExcludeURL
+ ac.PendingCount = wau.PendingCount
+
+ // URL-Check: beide URLs müssen gesetzt sein und passen
+ includeOK := expectedIncludeURL == "" || wau.IncludeURL == expectedIncludeURL
+ excludeOK := expectedExcludeURL == "" || wau.ExcludeURL == expectedExcludeURL
+ ac.WAUConfigOK = wau.Installed && includeOK && excludeOK
+ }
+
+ // Installierte Software als lowercase-Set für schnelles Lookup
+ installedSet := make(map[string]bool)
+ for _, s := range data.Windows.InstalledSoftware {
+ installedSet[strings.ToLower(s.Name)] = true
+ }
+
+ // Pro allow-Paket prüfen ob installiert
+ for _, wingetID := range allowRules {
+ displayName := displayNames[wingetID]
+ if displayName == "" {
+ displayName = wingetID
+ }
+ pkg := models.WAUPackageCompliance{
+ WingetID: wingetID,
+ DisplayName: displayName,
+ }
+ // Fuzzy-Match: display_name (lowercase) muss in installierter Software enthalten sein
+ lowerDisplay := strings.ToLower(displayName)
+ for installedName := range installedSet {
+ if strings.Contains(installedName, lowerDisplay) ||
+ strings.Contains(lowerDisplay, installedName) {
+ pkg.Installed = true
+ break
+ }
+ }
+ // Winget-ID-Teile als Fallback (z.B. "Mozilla.Firefox" → "firefox")
+ if !pkg.Installed {
+ parts := strings.Split(strings.ToLower(wingetID), ".")
+ if len(parts) > 1 {
+ appName := parts[len(parts)-1]
+ for installedName := range installedSet {
+ if strings.Contains(installedName, appName) {
+ pkg.Installed = true
+ break
+ }
+ }
+ }
+ }
+ ac.Packages = append(ac.Packages, pkg)
+ }
+
+ // Gesamt-Compliance: WAU installiert + korrekt + alle Pakete da + keine Updates ausstehend
+ allInstalled := true
+ for _, p := range ac.Packages {
+ if !p.Installed {
+ allInstalled = false
+ break
+ }
+ }
+ ac.Compliant = ac.WAUInstalled && ac.WAUConfigOK && allInstalled && ac.PendingCount == 0
+
+ result = append(result, ac)
+ }
+ if result == nil {
+ result = []models.WAUAgentCompliance{}
+ }
+ return result, rows.Err()
+}
+
+// GetWAUComplianceSummary gibt eine kompakte Zusammenfassung für die Kundenliste zurück
+func (d *Database) GetWAUComplianceSummary(customerID int, expectedIncludeURL, expectedExcludeURL string) (*models.WAUComplianceSummary, error) {
+ agents, err := d.GetWAUCompliance(customerID, expectedIncludeURL, expectedExcludeURL)
+ if err != nil {
+ return nil, err
+ }
+
+ summary := &models.WAUComplianceSummary{
+ CustomerID: customerID,
+ TotalAgents: len(agents),
+ }
+
+ for _, a := range agents {
+ if a.Compliant {
+ summary.CompliantAgents++
+ }
+ if !a.WAUInstalled {
+ summary.WAUMissing++
+ } else if !a.WAUConfigOK {
+ summary.WAUMisconfigured++
+ }
+ if a.PendingCount > 0 {
+ summary.UpdatesPending++
+ }
+ for _, p := range a.Packages {
+ if !p.Installed {
+ summary.PackagesMissing++
+ break // pro Agent nur einmal zählen
+ }
+ }
+ }
+
+ // Status ableiten
+ if summary.WAUMissing > 0 || summary.PackagesMissing > 0 {
+ summary.Status = "critical"
+ } else if summary.WAUMisconfigured > 0 || summary.UpdatesPending > 0 {
+ summary.Status = "warning"
+ } else if summary.TotalAgents == 0 {
+ summary.Status = "unknown"
+ } else {
+ summary.Status = "ok"
+ }
+
+ return summary, nil
+}
+
+// GetAllCustomersWAUSummary gibt Compliance-Summary für alle Kunden zurück
+func (d *Database) GetAllCustomersWAUSummary(includeURL, excludeURL string) ([]models.WAUComplianceSummary, error) {
+ rows, err := d.db.Query(`SELECT id FROM customers ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var summaries []models.WAUComplianceSummary
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ continue
+ }
+ s, err := d.GetWAUComplianceSummary(id, includeURL, excludeURL)
+ if err != nil {
+ continue
+ }
+ if s.TotalAgents > 0 {
+ summaries = append(summaries, *s)
+ }
+ }
+ if summaries == nil {
+ summaries = []models.WAUComplianceSummary{}
+ }
+ return summaries, rows.Err()
+}
diff --git a/backend/models/wau.go b/backend/models/wau.go
index 5055bec..d700dd1 100644
--- a/backend/models/wau.go
+++ b/backend/models/wau.go
@@ -17,3 +17,36 @@ type WAUSoftwareEntry struct {
DeviceCount int `json:"device_count"`
Versions []string `json:"versions"`
}
+
+// WAUPackageCompliance beschreibt den Compliance-Status eines einzelnen Pakets auf einem Gerät
+type WAUPackageCompliance struct {
+ WingetID string `json:"winget_id"`
+ DisplayName string `json:"display_name"`
+ Installed bool `json:"installed"`
+ HasUpdates bool `json:"has_updates"` // true wenn PendingCount > 0 und dieses Paket betroffen
+}
+
+// WAUAgentCompliance beschreibt den vollständigen WAU-Compliance-Status eines Agents
+type WAUAgentCompliance struct {
+ AgentID string `json:"agent_id"`
+ AgentName string `json:"agent_name"`
+ WAUInstalled bool `json:"wau_installed"`
+ WAUConfigOK bool `json:"wau_config_ok"` // Include/Exclude-URLs korrekt gesetzt
+ IncludeURL string `json:"include_url"` // tatsächlich konfigurierte URL
+ ExcludeURL string `json:"exclude_url"` // tatsächlich konfigurierte URL
+ PendingCount int `json:"pending_updates"`
+ Packages []WAUPackageCompliance `json:"packages"`
+ Compliant bool `json:"compliant"` // true wenn alles OK
+}
+
+// WAUComplianceSummary fasst den Compliance-Status eines Kunden zusammen (für die Kundenliste)
+type WAUComplianceSummary struct {
+ CustomerID int `json:"customer_id"`
+ TotalAgents int `json:"total_agents"`
+ CompliantAgents int `json:"compliant_agents"`
+ WAUMissing int `json:"wau_missing"` // Agents ohne WAU
+ WAUMisconfigured int `json:"wau_misconfigured"` // WAU installiert, aber falsche URLs
+ PackagesMissing int `json:"packages_missing"` // Agents mit fehlenden Pflichtpaketen
+ UpdatesPending int `json:"updates_pending"` // Agents mit ausstehenden Updates
+ Status string `json:"status"` // "ok" | "warning" | "critical"
+}
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..7522ead
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,6 @@
+# RMM Frontend Konfiguration
+# Datei kopieren: cp .env.example .env (wird nicht eingecheckt)
+
+VITE_BACKEND_HOST=your-backend.example.com
+VITE_BACKEND_PORT=8443
+VITE_API_KEY=your-api-key-here
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 4f11df1..2d5fd28 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -275,6 +275,14 @@ class ApiClient {
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
}
+ getWAUCompliance(customerId) {
+ return this.get(`/api/v1/customers/${customerId}/wau/compliance`)
+ }
+
+ getWAUComplianceSummary() {
+ return this.get('/api/v1/wau/compliance/summary')
+ }
+
createAPIKey(name, permissions = 'read', customerId = null) {
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
}
diff --git a/frontend/src/config.js b/frontend/src/config.js
new file mode 100644
index 0000000..be21c1d
--- /dev/null
+++ b/frontend/src/config.js
@@ -0,0 +1,8 @@
+// Frontend-Konfiguration
+// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime), 2. Vite ENV (lokaler Build)
+const rt = window.__RMM_CONFIG__ || {}
+
+export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
+export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
+export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
+export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''
diff --git a/frontend/src/pages/Customers.jsx b/frontend/src/pages/Customers.jsx
index 633e1f8..2d48b8c 100644
--- a/frontend/src/pages/Customers.jsx
+++ b/frontend/src/pages/Customers.jsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { copyToClipboard } from '../utils/clipboard'
-import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, Package, Search, RefreshCw } from 'lucide-react'
+import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, ShieldAlert, Package, Search, RefreshCw, CircleCheck, CircleX, CircleAlert, Info } from 'lucide-react'
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
@@ -10,6 +10,45 @@ const PERM_LABELS = {
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
}
+// ── WAU Compliance Badge ──────────────────────────────────────────────────────
+function WAUComplianceBadge({ summary }) {
+ if (!summary) return null
+ if (summary.total_agents === 0) return null
+
+ const { status, compliant_agents, total_agents, wau_missing, wau_misconfigured, packages_missing, updates_pending } = summary
+
+ const configs = {
+ ok: { icon: CircleCheck, cls: 'text-green-400', bg: 'bg-green-900/20 border-green-700/40', label: `WAU OK (${compliant_agents}/${total_agents})` },
+ warning: { icon: CircleAlert, cls: 'text-yellow-400', bg: 'bg-yellow-900/20 border-yellow-700/40', label: buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) },
+ critical: { icon: CircleX, cls: 'text-red-400', bg: 'bg-red-900/20 border-red-700/40', label: buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) },
+ unknown: { icon: Info, cls: 'text-gray-500', bg: 'bg-gray-800/40 border-gray-700/40', label: 'Keine Windows-Agents' },
+ }
+
+ const cfg = configs[status] || configs.unknown
+ const Icon = cfg.icon
+
+ return (
+
+