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:
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user