feat: WAU-Integration + Windows Software-Inventar (v1.1.0)

Windows Agent (v1.1.0):
- Software-Inventar aus Windows-Registry (64-bit + 32-bit)
  Sammelt Name, Version, Hersteller, InstallDate aller installierten Apps
  Filtert SystemComponent-Einträge heraus, sortiert alphabetisch
  Funktioniert unter SYSTEM-Account (kein winget nötig)
- Version auf 1.1.0 gehoben

Backend:
- customer_wau_rules Tabelle (Migration automatisch)
- WAU-Regeln pro Kunde (allow/block, winget-ID + Anzeigename)
- GET/POST/DELETE /api/v1/customers/{id}/wau/rules
- GET /api/v1/customers/{id}/wau/inventory  (SQL-Aggregation aus Agenten-Daten)
- GET /api/v1/customers/{id}/wau/included   (Plaintext für WAU)
- GET /api/v1/customers/{id}/wau/excluded   (Plaintext für WAU)

Frontend:
- Kunden-Seite: Tab-Navigation (Agents | API-Keys | WAU)
- WAU-Tab pro Kunde: Allowlist + Blocklist mit Inline-Assign aus Inventar
  Allow/Block-Buttons direkt in Inventar-Zeile, winget-ID vorgeschlagen
  Installationsbefehl mit externer Backend-URL aus Systemeinstellungen
- Windows-Panel Software-Tab: Installiert-Liste aus Registry-Daten
- Windows-Panel WAU-Tab: Status (installiert/Task/letzter Lauf)
  WAU installieren via MSI-Download + msiexec (synchron)
  Jetzt ausführen, Log anzeigen
  Gewünschten Zustand herstellen (Allowlist auf Client installieren)
This commit is contained in:
cynfo3000
2026-03-10 08:46:00 +01:00
parent 7a5f4c0564
commit bf51ed8569
10 changed files with 1242 additions and 131 deletions
+3
View File
@@ -61,6 +61,9 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
// WAU-Regeln
h.setupWAURoutes(mux)
// System Settings
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
mux.HandleFunc("PUT /api/v1/settings", h.updateSettings)
+151
View File
@@ -0,0 +1,151 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
// 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("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
// 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)
mux.HandleFunc("GET /api/v1/customers/{id}/wau/excluded", h.wauExcludedList)
}
// GET /api/v1/customers/{id}/wau/rules
func (h *Handler) listWAURules(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
}
rules, err := h.db.ListWAURules(customerID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
return
}
writeJSON(w, http.StatusOK, rules)
}
// POST /api/v1/customers/{id}/wau/rules
func (h *Handler) addWAURule(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
}
var req struct {
WingetID string `json:"winget_id"`
DisplayName string `json:"display_name"`
RuleType string `json:"rule_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
req.WingetID = strings.TrimSpace(req.WingetID)
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.WingetID == "" {
writeError(w, http.StatusBadRequest, "winget_id erforderlich")
return
}
if req.RuleType != "allow" && req.RuleType != "block" {
writeError(w, http.StatusBadRequest, "rule_type muss 'allow' oder 'block' sein")
return
}
if req.DisplayName == "" {
req.DisplayName = req.WingetID
}
rule, err := h.db.AddWAURule(customerID, req.WingetID, req.DisplayName, req.RuleType)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern")
return
}
h.auditLog(r, "wau.rule.add", "customer", strconv.Itoa(customerID), "",
req.RuleType+": "+req.WingetID)
writeJSON(w, http.StatusCreated, rule)
}
// DELETE /api/v1/customers/{id}/wau/rules/{rid}
func (h *Handler) deleteWAURule(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
}
ruleID, err := strconv.Atoi(r.PathValue("rid"))
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Regel-ID")
return
}
ok, err := h.db.DeleteWAURule(customerID, ruleID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen")
return
}
if !ok {
writeError(w, http.StatusNotFound, "Regel nicht gefunden")
return
}
h.auditLog(r, "wau.rule.delete", "customer", strconv.Itoa(customerID), "", strconv.Itoa(ruleID))
writeJSON(w, http.StatusOK, map[string]string{"message": "Regel gelöscht"})
}
// GET /api/v1/customers/{id}/wau/inventory
func (h *Handler) getWAUInventory(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
}
entries, err := h.db.GetWAUSoftwareInventory(customerID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des Inventars")
return
}
writeJSON(w, http.StatusOK, entries)
}
// GET /api/v1/customers/{id}/wau/included → Plaintext für WAU
func (h *Handler) wauIncludedList(w http.ResponseWriter, r *http.Request) {
h.serveWAUList(w, r, "allow")
}
// GET /api/v1/customers/{id}/wau/excluded → Plaintext für WAU
func (h *Handler) wauExcludedList(w http.ResponseWriter, r *http.Request) {
h.serveWAUList(w, r, "block")
}
func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType string) {
customerID, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "Ungültige Kunden-ID", http.StatusBadRequest)
return
}
ids, err := h.db.GetWAUList(customerID, ruleType)
if err != nil {
http.Error(w, "Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(strings.Join(ids, "\r\n")))
if len(ids) > 0 {
w.Write([]byte("\r\n"))
}
}
+12
View File
@@ -280,6 +280,18 @@ func (d *Database) migrate() error {
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
// WAU-Regeln pro Kunde
d.db.Exec(`CREATE TABLE IF NOT EXISTS customer_wau_rules (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
winget_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL CHECK (rule_type IN ('allow', 'block')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(customer_id, winget_id)
)`)
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_wau_rules_customer ON customer_wau_rules(customer_id, rule_type)`)
// Retention Policy (90 Tage)
d.setupRetention()
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"database/sql"
"encoding/json"
"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()
}
+19
View File
@@ -0,0 +1,19 @@
package models
import "time"
type WAURule struct {
ID int `json:"id"`
CustomerID int `json:"customer_id"`
WingetID string `json:"winget_id"`
DisplayName string `json:"display_name"`
RuleType string `json:"rule_type"` // "allow" | "block"
CreatedAt time.Time `json:"created_at"`
}
type WAUSoftwareEntry struct {
Name string `json:"name"`
Publisher string `json:"publisher"`
DeviceCount int `json:"device_count"`
Versions []string `json:"versions"`
}