rmm2/backend/db/wau.go
cynfo3000 54ddc0b902 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
2026-03-10 17:38:58 +01:00

351 lines
9.9 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"strings"
"github.com/cynfo/rmm-backend/models"
)
// ListWAURules gibt alle WAU-Regeln eines Kunden zurück
func (d *Database) ListWAURules(customerID int) ([]models.WAURule, error) {
rows, err := d.db.Query(`
SELECT id, customer_id, winget_id, display_name, rule_type, created_at
FROM customer_wau_rules
WHERE customer_id = $1
ORDER BY rule_type, lower(display_name)
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []models.WAURule
for rows.Next() {
var r models.WAURule
if err := rows.Scan(&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt); err != nil {
return nil, err
}
rules = append(rules, r)
}
if rules == nil {
rules = []models.WAURule{}
}
return rules, rows.Err()
}
// AddWAURule fügt eine neue Regel hinzu (upsert auf winget_id)
func (d *Database) AddWAURule(customerID int, wingetID, displayName, ruleType string) (*models.WAURule, error) {
var r models.WAURule
err := d.db.QueryRow(`
INSERT INTO customer_wau_rules (customer_id, winget_id, display_name, rule_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (customer_id, winget_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
rule_type = EXCLUDED.rule_type
RETURNING id, customer_id, winget_id, display_name, rule_type, created_at
`, customerID, wingetID, displayName, ruleType).Scan(
&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt,
)
if err != nil {
return nil, err
}
return &r, nil
}
// DeleteWAURule löscht eine Regel (per ID, gehört zu customerID)
func (d *Database) DeleteWAURule(customerID, ruleID int) (bool, error) {
res, err := d.db.Exec(`
DELETE FROM customer_wau_rules WHERE id = $1 AND customer_id = $2
`, ruleID, customerID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// GetWAUList gibt alle winget-IDs eines bestimmten Typs zurück (für den Plaintext-Endpoint)
func (d *Database) GetWAUList(customerID int, ruleType string) ([]string, error) {
rows, err := d.db.Query(`
SELECT winget_id FROM customer_wau_rules
WHERE customer_id = $1 AND rule_type = $2
ORDER BY lower(winget_id)
`, customerID, ruleType)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetWAUSoftwareInventory aggregiert installierte Software aller Windows-Agents eines Kunden
func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftwareEntry, error) {
rows, err := d.db.Query(`
SELECT
app->>'name' AS name,
COALESCE(app->>'publisher', '') AS publisher,
COUNT(DISTINCT sd.agent_id) AS device_count,
jsonb_agg(DISTINCT app->>'version') FILTER (WHERE (app->>'version') != '') AS versions
FROM agents a
JOIN system_data sd ON sd.agent_id = a.id
CROSS JOIN jsonb_array_elements(sd.data_json->'windows'->'installed_software') AS app
WHERE a.customer_id = $1
AND sd.data_json ? 'windows'
AND jsonb_typeof(sd.data_json->'windows'->'installed_software') = 'array'
AND (app->>'name') IS NOT NULL
AND (app->>'name') != ''
GROUP BY app->>'name', app->>'publisher'
ORDER BY lower(app->>'name')
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []models.WAUSoftwareEntry
for rows.Next() {
var e models.WAUSoftwareEntry
var versionsJSON sql.NullString
if err := rows.Scan(&e.Name, &e.Publisher, &e.DeviceCount, &versionsJSON); err != nil {
return nil, err
}
if versionsJSON.Valid && versionsJSON.String != "" && versionsJSON.String != "null" {
json.Unmarshal([]byte(versionsJSON.String), &e.Versions)
}
if e.Versions == nil {
e.Versions = []string{}
}
entries = append(entries, e)
}
if entries == nil {
entries = []models.WAUSoftwareEntry{}
}
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()
}