WAU Compliance: agent collector, backend endpoints, frontend badge + panel

- Agent: WAU registry config check (include/exclude URLs), pending updates count
- Backend: /wau/compliance per customer, /wau/compliance/summary all customers
- Frontend: compliance badge in customer list, compliance panel in WAU tab
- config.js: no longer gitignored (secrets-free, reads from env/window.__RMM_CONFIG__)
- vite.config.js: reads backend URL from .env instead of hardcoded value
- .env.example added for clean setup
This commit is contained in:
cynfo3000
2026-03-10 17:38:58 +01:00
parent bf51ed8569
commit 54ddc0b902
12 changed files with 677 additions and 19 deletions
+55 -3
View File
@@ -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)
}
+215
View File
@@ -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()
}
+33
View File
@@ -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"
}