diff --git a/.gitignore b/.gitignore index 698f0d0..fb0bd15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ build/ +rmm-agent +rmm-backend *.db *.log *.pid diff --git a/README.md b/README.md index 3ad387c..bdb2fbb 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,19 @@ daemon -f -p /var/run/rmm-agent.pid -o /usr/local/rmm/rmm-agent.log \ | POST | `/api/v1/agents/{id}/major-update` | Major-Upgrade auf Zielversion | | POST | `/api/v1/agents/{id}/reboot` | Reboot ausfuehren | +### Config-Backup + +| Methode | Endpoint | Beschreibung | +|---------|----------|-------------| +| POST | `/api/v1/agents/{id}/backup` | Backup jetzt ausloesen (via WebSocket) | +| GET | `/api/v1/agents/{id}/backups` | Alle Backups auflisten (`?limit=N`) | +| GET | `/api/v1/agents/{id}/backups/{id\|latest}` | Backup herunterladen (`?format=xml` fuer Raw-XML) | +| DELETE | `/api/v1/agents/{id}/backups/{id}` | Einzelnes Backup loeschen | +| GET | `/api/v1/agents/{id}/backups/diff?a={id}&b={id}` | Zeilenweises Diff zwischen zwei Backups | + +Backups werden dedupliziert: gleicher SHA256-Hash = kein neuer Eintrag. +Config-XML wird Base64-kodiert vom Agent zum Backend uebertragen und als Klartext in SQLite gespeichert. + ### Tunnel-Management | Methode | Endpoint | Beschreibung | @@ -208,6 +221,27 @@ curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ -d '{"command":"uptime","timeout":30}' ``` +### Backup-Beispiele + +```bash +# Backup ausloesen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backup + +# Alle Backups auflisten +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backups + +# Neuestes Backup als XML herunterladen +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backups/latest?format=xml" \ + -o config-backup.xml + +# Diff zwischen Backup 1 und 3 +curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \ + "https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887/backups/diff?a=1&b=3" +``` + ### Update-Beispiele ```bash @@ -269,6 +303,9 @@ Major-Upgrades setzen `reboot_required: true` und `reboot` ist standardmaessig a // Reboot {"type":"command","id":"cmd5","command":"reboot","params":{"delay":5}} +// Config-Backup (liest /conf/config.xml, liefert Base64 + SHA256 Hash) +{"type":"command","id":"cmd8","command":"backup","params":{}} + // Tunnel-Session oeffnen (automatisch vom Backend gesendet) {"type":"command","id":"cmd6","command":"tunnel_connect","params":{"session_id":"xxx","tunnel_id":"xxx","target_host":"127.0.0.1","target_port":22}} @@ -324,7 +361,8 @@ rmm/ │ │ └── system.go # SystemData, alle Collector-Structs │ ├── api/ │ │ ├── handlers.go # REST API Handler -│ │ ├── tunnel_proxy.go # Tunnel + Exec REST Endpoints +│ │ ├── tunnel_proxy.go # Tunnel + Exec + Update REST Endpoints +│ │ ├── backup.go # Config-Backup Endpoints (trigger, list, download, diff) │ │ └── middleware.go # API-Key Auth, Logging │ └── ws/ │ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps diff --git a/agent/ws/handler.go b/agent/ws/handler.go index bb83ed1..321b1f5 100644 --- a/agent/ws/handler.go +++ b/agent/ws/handler.go @@ -2,8 +2,12 @@ package ws import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "fmt" "log" + "os" "os/exec" "strconv" "strings" @@ -36,6 +40,8 @@ func (h *Handler) HandleCommand(msg Message) { response = h.handleUpdateCheck(msg) case "reboot": response = h.handleReboot(msg) + case "backup": + response = h.handleBackup(msg) case "tunnel_connect": response = h.handleTunnelConnect(msg) case "tunnel_disconnect": @@ -243,7 +249,10 @@ func (h *Handler) handleUpdate(msg Message) Message { } // handleMajorUpdate fuehrt ein Major-Upgrade durch -// Ablauf: opnsense-update -r -bkp → pkg-static update -f → pkg-static upgrade -y → Reboot +// Ablauf: Phase 1: opnsense-update -r -bkp → Reboot +// Phase 2 (nach Reboot): pkg-static update -f → pkg-static upgrade -y → optionaler Reboot +// WICHTIG: Das neue Paket-Repository wird erst NACH dem Reboot aktiv, +// daher muss pkg upgrade zwingend nach dem Reboot laufen. func (h *Handler) handleMajorUpdate(msg Message) Message { response := Message{Type: "response", ID: msg.ID} @@ -254,21 +263,100 @@ func (h *Handler) handleMajorUpdate(msg Message) Message { return response } - reboot := true // Major-Update braucht immer Reboot, default an - if r, ok := msg.Params["reboot"].(bool); ok { - reboot = r + // Phase 2: Wenn phase=2, nur pkg upgrade ausfuehren (nach Reboot) + phase := "1" + if p, ok := msg.Params["phase"].(string); ok && p != "" { + phase = p + } else if p, ok := msg.Params["phase"].(float64); ok { + phase = fmt.Sprintf("%.0f", p) } - log.Printf("Major-Update auf Version %s gestartet...", targetVersion) + log.Printf("Major-Update auf Version %s, Phase %s gestartet...", targetVersion, phase) result := map[string]interface{}{ "target_version": targetVersion, + "phase": phase, } var steps []map[string]interface{} hasError := false - // Schritt 1: opnsense-update -r -bkp (Release wechseln + Base + Kernel + Packages) - log.Printf("Schritt 1: opnsense-update -r %s -bkp", targetVersion) + if phase == "2" { + // Phase 2: Nach Reboot — Repo auf neue Version umstellen, dann pkg upgrade + // Das OPNsense pkg-Repo wird vom opnsense Package kontrolliert (CORE_REPOSITORY in /usr/local/opnsense/version/core) + // Nach opnsense-update -r -bkp + Reboot zeigt das Repo noch die alte Version + // Wir muessen die Repo-Config manuell umschreiben + repoConf := "/usr/local/etc/pkg/repos/OPNsense.conf" + log.Printf("Phase 2: Repo-Config auf %s umstellen", targetVersion) + // Aktuelle Series aus dem Repo-Config lesen und durch targetVersion ersetzen + sedCmd := fmt.Sprintf("/usr/bin/sed -i '' 's|/[0-9][0-9]\\.[0-9]/latest|/%s/latest|g' %s", targetVersion, repoConf) + sedOutput, sedErr := h.runCommand(sedCmd, 10) + step0 := map[string]interface{}{ + "step": "repo-switch to " + targetVersion, + "output": sedOutput, + "ok": sedErr == nil, + } + if sedErr != nil { + step0["error"] = sedErr.Error() + hasError = true + } + steps = append(steps, step0) + + log.Println("Phase 2: pkg-static update -f") + catalogOutput, catalogErr := h.runCommand("/usr/local/sbin/pkg-static update -f", 120) + step1 := map[string]interface{}{ + "step": "pkg-static update -f", + "output": catalogOutput, + "ok": catalogErr == nil, + } + if catalogErr != nil { + step1["error"] = catalogErr.Error() + hasError = true + } + steps = append(steps, step1) + + log.Println("Phase 2: pkg-static upgrade -y") + pkgOutput, pkgErr := h.runCommand("/usr/local/sbin/pkg-static upgrade -y", 900) + step2 := map[string]interface{}{ + "step": "pkg-static upgrade -y", + "output": pkgOutput, + "ok": pkgErr == nil, + } + if pkgErr != nil { + step2["error"] = pkgErr.Error() + hasError = true + } + steps = append(steps, step2) + + // Verify + verOutput, _ := h.runCommand("/usr/local/sbin/opnsense-version", 10) + verStep := map[string]interface{}{ + "step": "verify", + "output": strings.TrimSpace(verOutput), + "ok": true, + } + steps = append(steps, verStep) + + result["steps"] = steps + result["reboot_required"] = false + + if !hasError { + result["message"] = fmt.Sprintf("Major-Update Phase 2 auf %s abgeschlossen — Packages aktualisiert", targetVersion) + } else { + result["message"] = fmt.Sprintf("Major-Update Phase 2 auf %s mit Fehlern", targetVersion) + } + + response.Status = "ok" + response.Data = result + return response + } + + // Phase 1: Base + Kernel installieren, dann Reboot + reboot := true + if r, ok := msg.Params["reboot"].(bool); ok { + reboot = r + } + + log.Printf("Phase 1: opnsense-update -r %s -bkp", targetVersion) releaseCmd := fmt.Sprintf("/usr/local/sbin/opnsense-update -r %s -bkp", targetVersion) releaseOutput, releaseErr := h.runCommand(releaseCmd, 900) step1 := map[string]interface{}{ @@ -282,48 +370,20 @@ func (h *Handler) handleMajorUpdate(msg Message) Message { } steps = append(steps, step1) - // Schritt 2: pkg-static update -f (Repository-Katalog auf neuen Release aktualisieren) - log.Println("Schritt 2: pkg-static update -f") - catalogOutput, catalogErr := h.runCommand("/usr/local/sbin/pkg-static update -f", 120) - step2 := map[string]interface{}{ - "step": "pkg-static update -f", - "output": catalogOutput, - "ok": catalogErr == nil, - } - if catalogErr != nil { - step2["error"] = catalogErr.Error() - hasError = true - } - steps = append(steps, step2) - - // Schritt 3: pkg-static upgrade -y (alle Pakete auf neue Version) - log.Println("Schritt 3: pkg-static upgrade -y") - pkgOutput, pkgErr := h.runCommand("/usr/local/sbin/pkg-static upgrade -y", 900) - step3 := map[string]interface{}{ - "step": "pkg-static upgrade -y", - "output": pkgOutput, - "ok": pkgErr == nil, - } - if pkgErr != nil { - step3["error"] = pkgErr.Error() - hasError = true - } - steps = append(steps, step3) - result["steps"] = steps result["reboot_required"] = true result["reboot_requested"] = reboot if !hasError { - result["message"] = fmt.Sprintf("Major-Update auf %s abgeschlossen — Reboot erforderlich", targetVersion) + result["message"] = fmt.Sprintf("Major-Update Phase 1 auf %s abgeschlossen — Reboot erforderlich, danach Phase 2 ausfuehren", targetVersion) } else { - result["message"] = fmt.Sprintf("Major-Update auf %s mit Warnungen — Reboot erforderlich", targetVersion) + result["message"] = fmt.Sprintf("Major-Update Phase 1 auf %s mit Fehlern", targetVersion) } response.Status = "ok" if reboot { - log.Println("Reboot nach Major-Update angefordert...") + log.Println("Reboot nach Phase 1 angefordert...") result["reboot"] = "Reboot in 5 Sekunden..." response.Data = result @@ -337,7 +397,7 @@ func (h *Handler) handleMajorUpdate(msg Message) Message { } response.Data = result - log.Printf("Major-Update auf %s abgeschlossen (Reboot noch ausstehend)", targetVersion) + log.Printf("Major-Update Phase 1 auf %s abgeschlossen (Reboot noch ausstehend)", targetVersion) return response } @@ -366,6 +426,44 @@ func (h *Handler) handleReboot(msg Message) Message { return response } +// --- Backup Handler --- + +// handleBackup liest die OPNsense config.xml und sendet sie zurueck +func (h *Handler) handleBackup(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + configPath := "/conf/config.xml" + if p, ok := msg.Params["path"].(string); ok && p != "" { + configPath = p + } + + data, err := os.ReadFile(configPath) + if err != nil { + response.Status = "error" + response.Error = fmt.Sprintf("Config lesen fehlgeschlagen: %v", err) + return response + } + + // SHA256-Hash berechnen + hash := sha256.Sum256(data) + hashStr := hex.EncodeToString(hash[:]) + + // Base64-kodiert senden (config.xml ist Text, aber sicherstellen dass nichts verloren geht) + encoded := base64.StdEncoding.EncodeToString(data) + + response.Status = "ok" + response.Data = map[string]interface{}{ + "config": encoded, + "hash": hashStr, + "size": len(data), + "path": configPath, + "encoding": "base64", + } + + log.Printf("Backup erstellt: %s (%d Bytes, Hash: %s)", configPath, len(data), hashStr[:12]) + return response +} + // --- Tunnel Handlers --- func (h *Handler) handleTunnelConnect(msg Message) Message { diff --git a/backend/api/backup.go b/backend/api/backup.go new file mode 100644 index 0000000..bb85b1b --- /dev/null +++ b/backend/api/backup.go @@ -0,0 +1,335 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/cynfo/rmm-backend/ws" +) + +// POST /api/v1/agents/{id}/backup — Backup ausloesen (via WebSocket an Agent) +func (h *Handler) triggerBackup(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + + if !hub.IsAgentConnected(agentID) { + writeError(w, http.StatusServiceUnavailable, "Agent nicht verbunden") + return + } + + cmdID := generateID() + + cmd := map[string]interface{}{ + "type": "command", + "id": cmdID, + "command": "backup", + "params": map[string]interface{}{}, + } + + // Response-Channel registrieren + responseCh := hub.RegisterResponseWaiter(cmdID) + defer hub.UnregisterResponseWaiter(cmdID) + + cmdJSON, _ := json.Marshal(cmd) + if err := hub.SendToAgent(agentID, cmdJSON); err != nil { + log.Printf("Backup-Command senden fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Command senden fehlgeschlagen") + return + } + + log.Printf("Backup-Command (ID: %s) an Agent %s gesendet", cmdID, agentID) + + // Auf Response warten (config.xml kann gross sein, 60s Timeout) + select { + case resp := <-responseCh: + status, _ := resp["status"].(string) + if status != "ok" { + errMsg, _ := resp["error"].(string) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Backup fehlgeschlagen: %s", errMsg)) + return + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + writeError(w, http.StatusInternalServerError, "Ungueltiges Response-Format") + return + } + + // Config aus Base64 dekodieren + configB64, _ := data["config"].(string) + hash, _ := data["hash"].(string) + sizeFloat, _ := data["size"].(float64) + size := int(sizeFloat) + + configXML, err := base64.StdEncoding.DecodeString(configB64) + if err != nil { + writeError(w, http.StatusInternalServerError, "Base64-Dekodierung fehlgeschlagen") + return + } + + // In DB speichern (dedupliziert) + backupID, isNew, err := h.db.SaveConfigBackup(agentID, hash, size, string(configXML)) + if err != nil { + log.Printf("Backup speichern fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Backup speichern fehlgeschlagen") + return + } + + msg := "Backup gespeichert" + if !isNew { + msg = "Config unveraendert (Hash identisch)" + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "backup_id": backupID, + "hash": hash, + "size": size, + "new": isNew, + "message": msg, + }) + + case <-time.After(60 * time.Second): + writeError(w, http.StatusGatewayTimeout, "Timeout: Agent hat nicht rechtzeitig geantwortet") + } + } +} + +// GET /api/v1/agents/{id}/backups — Alle Backups auflisten +func (h *Handler) listBackups() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + backups, err := h.db.GetConfigBackups(agentID, limit) + if err != nil { + log.Printf("Backups laden fehlgeschlagen: %v", err) + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + writeJSON(w, http.StatusOK, backups) + } +} + +// GET /api/v1/agents/{id}/backups/{backup_id} — Einzelnes Backup herunterladen +func (h *Handler) getBackup() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + backupIDStr := r.PathValue("backup_id") + + // "latest" als Spezialwert + if backupIDStr == "latest" { + backup, err := h.db.GetLatestConfigBackup(agentID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if backup == nil { + writeError(w, http.StatusNotFound, "Kein Backup vorhanden") + return + } + + // Format: raw XML oder JSON? + if r.URL.Query().Get("format") == "xml" { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=config_%s.xml", agentID[:8])) + w.Write([]byte(backup["config_xml"].(string))) + return + } + writeJSON(w, http.StatusOK, backup) + return + } + + backupID, err := strconv.ParseInt(backupIDStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Backup-ID") + return + } + + backup, err := h.db.GetConfigBackup(agentID, backupID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if backup == nil { + writeError(w, http.StatusNotFound, "Backup nicht gefunden") + return + } + + // Raw XML Download + if r.URL.Query().Get("format") == "xml" { + w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=config_%s_%d.xml", agentID[:8], backupID)) + w.Write([]byte(backup["config_xml"].(string))) + return + } + + writeJSON(w, http.StatusOK, backup) + } +} + +// DELETE /api/v1/agents/{id}/backups/{backup_id} — Backup loeschen +func (h *Handler) deleteBackup() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + backupIDStr := r.PathValue("backup_id") + + backupID, err := strconv.ParseInt(backupIDStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Backup-ID") + return + } + + deleted, err := h.db.DeleteConfigBackup(agentID, backupID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Loeschen fehlgeschlagen") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "Backup nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "Backup geloescht"}) + } +} + +// GET /api/v1/agents/{id}/backups/diff?a={id1}&b={id2} — Diff zwischen zwei Backups +func (h *Handler) diffBackups() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + + aStr := r.URL.Query().Get("a") + bStr := r.URL.Query().Get("b") + if aStr == "" || bStr == "" { + writeError(w, http.StatusBadRequest, "Parameter 'a' und 'b' (Backup-IDs) erforderlich") + return + } + + aID, err := strconv.ParseInt(aStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Backup-ID 'a'") + return + } + bID, err := strconv.ParseInt(bStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Backup-ID 'b'") + return + } + + backupA, err := h.db.GetConfigBackup(agentID, aID) + if err != nil || backupA == nil { + writeError(w, http.StatusNotFound, fmt.Sprintf("Backup %d nicht gefunden", aID)) + return + } + backupB, err := h.db.GetConfigBackup(agentID, bID) + if err != nil || backupB == nil { + writeError(w, http.StatusNotFound, fmt.Sprintf("Backup %d nicht gefunden", bID)) + return + } + + xmlA := backupA["config_xml"].(string) + xmlB := backupB["config_xml"].(string) + + if backupA["hash"] == backupB["hash"] { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "identical": true, + "a": map[string]interface{}{"id": aID, "hash": backupA["hash"], "created_at": backupA["created_at"]}, + "b": map[string]interface{}{"id": bID, "hash": backupB["hash"], "created_at": backupB["created_at"]}, + "diff": "", + }) + return + } + + // Zeilenweises Diff berechnen + diff := computeLineDiff(xmlA, xmlB) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "identical": false, + "a": map[string]interface{}{"id": aID, "hash": backupA["hash"], "created_at": backupA["created_at"]}, + "b": map[string]interface{}{"id": bID, "hash": backupB["hash"], "created_at": backupB["created_at"]}, + "diff": diff, + }) + } +} + +// computeLineDiff erzeugt ein einfaches zeilenweises Diff (unified-style) +func computeLineDiff(a, b string) string { + linesA := strings.Split(a, "\n") + linesB := strings.Split(b, "\n") + + var result []string + maxLen := len(linesA) + if len(linesB) > maxLen { + maxLen = len(linesB) + } + + // Einfaches sequenzielles Diff — fuer XML config.xml voellig ausreichend + i, j := 0, 0 + for i < len(linesA) || j < len(linesB) { + if i < len(linesA) && j < len(linesB) && linesA[i] == linesB[j] { + // Gleiche Zeile — uebergehen (nur Context bei Aenderungen zeigen) + i++ + j++ + continue + } + + // Vorausschauen ob die Zeile weiter unten in der anderen Datei existiert + foundInB := -1 + if i < len(linesA) { + for k := j; k < len(linesB) && k < j+10; k++ { + if linesA[i] == linesB[k] { + foundInB = k + break + } + } + } + + foundInA := -1 + if j < len(linesB) { + for k := i; k < len(linesA) && k < i+10; k++ { + if linesB[j] == linesA[k] { + foundInA = k + break + } + } + } + + if foundInB >= 0 && (foundInA < 0 || foundInB-j <= foundInA-i) { + // Zeilen in B eingefuegt + for j < foundInB { + result = append(result, fmt.Sprintf("+ %s", linesB[j])) + j++ + } + } else if foundInA >= 0 { + // Zeilen in A entfernt + for i < foundInA { + result = append(result, fmt.Sprintf("- %s", linesA[i])) + i++ + } + } else { + // Zeile geaendert + if i < len(linesA) { + result = append(result, fmt.Sprintf("- %s", linesA[i])) + i++ + } + if j < len(linesB) { + result = append(result, fmt.Sprintf("+ %s", linesB[j])) + j++ + } + } + } + + return strings.Join(result, "\n") +} diff --git a/backend/api/tunnel_proxy.go b/backend/api/tunnel_proxy.go index 1e73723..3c3742a 100644 --- a/backend/api/tunnel_proxy.go +++ b/backend/api/tunnel_proxy.go @@ -26,6 +26,13 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("PUT /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub)) mux.HandleFunc("DELETE /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub)) + // Backup-Endpoints + mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub)) + mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups()) + mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup()) + mux.HandleFunc("DELETE /api/v1/agents/{id}/backups/{backup_id}", h.deleteBackup()) + mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups()) + // WebSocket-Endpoint mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub)) @@ -212,9 +219,10 @@ func (h *Handler) agentMajorUpdate(hub *ws.Hub) http.HandlerFunc { var req struct { Version string `json:"version"` Reboot bool `json:"reboot"` + Phase string `json:"phase"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Version == "" { - writeError(w, http.StatusBadRequest, "Parameter 'version' erforderlich (z.B. '26.7')") + writeError(w, http.StatusBadRequest, "Parameter 'version' erforderlich (z.B. '26.1')") return } @@ -222,6 +230,9 @@ func (h *Handler) agentMajorUpdate(hub *ws.Hub) http.HandlerFunc { "version": req.Version, "reboot": req.Reboot, } + if req.Phase != "" { + params["phase"] = req.Phase + } h.sendCommandAndWait(hub, agentID, "major_update", params, 1200, w) } diff --git a/backend/db/sqlite.go b/backend/db/sqlite.go index 490e8ec..efa274c 100644 --- a/backend/db/sqlite.go +++ b/backend/db/sqlite.go @@ -46,6 +46,19 @@ func (d *Database) migrate() error { last_heartbeat TEXT ); + CREATE TABLE IF NOT EXISTS config_backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + hash TEXT NOT NULL, + size INTEGER NOT NULL, + config_xml TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_config_backups_agent ON config_backups(agent_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_config_backups_hash ON config_backups(agent_id, hash); + CREATE TABLE IF NOT EXISTS system_data ( agent_id TEXT PRIMARY KEY, data_json TEXT NOT NULL, @@ -219,6 +232,123 @@ func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) { return &data, nil } +// --- Config Backup Methoden --- + +// SaveConfigBackup speichert ein Config-Backup (dedupliziert nach Hash) +func (d *Database) SaveConfigBackup(agentID, hash string, size int, configXML string) (int64, bool, error) { + // Pruefen ob ein Backup mit gleichem Hash schon existiert + var existingID int64 + err := d.db.QueryRow("SELECT id FROM config_backups WHERE agent_id = ? AND hash = ? LIMIT 1", agentID, hash).Scan(&existingID) + if err == nil { + // Gleicher Hash existiert bereits — kein neues Backup noetig + return existingID, false, nil + } + + now := time.Now().UTC().Format(time.RFC3339) + res, err := d.db.Exec( + "INSERT INTO config_backups (agent_id, hash, size, config_xml, created_at) VALUES (?, ?, ?, ?, ?)", + agentID, hash, size, configXML, now, + ) + if err != nil { + return 0, false, fmt.Errorf("Backup speichern: %w", err) + } + id, _ := res.LastInsertId() + return id, true, nil +} + +// GetConfigBackups listet alle Backups eines Agents +func (d *Database) GetConfigBackups(agentID string, limit int) ([]map[string]interface{}, error) { + if limit <= 0 { + limit = 50 + } + rows, err := d.db.Query( + "SELECT id, hash, size, created_at FROM config_backups WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?", + agentID, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var backups []map[string]interface{} + for rows.Next() { + var id int64 + var hash, createdAt string + var size int + if err := rows.Scan(&id, &hash, &size, &createdAt); err != nil { + return nil, err + } + backups = append(backups, map[string]interface{}{ + "id": id, + "hash": hash, + "size": size, + "created_at": createdAt, + }) + } + if backups == nil { + backups = []map[string]interface{}{} + } + return backups, rows.Err() +} + +// GetConfigBackup holt ein einzelnes Backup inkl. config_xml +func (d *Database) GetConfigBackup(agentID string, backupID int64) (map[string]interface{}, error) { + var hash, configXML, createdAt string + var size int + var id int64 + err := d.db.QueryRow( + "SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = ? AND id = ?", + agentID, backupID, + ).Scan(&id, &hash, &size, &configXML, &createdAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return map[string]interface{}{ + "id": id, + "hash": hash, + "size": size, + "config_xml": configXML, + "created_at": createdAt, + }, nil +} + +// GetLatestConfigBackup holt das neueste Backup +func (d *Database) GetLatestConfigBackup(agentID string) (map[string]interface{}, error) { + var hash, configXML, createdAt string + var size int + var id int64 + err := d.db.QueryRow( + "SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1", + agentID, + ).Scan(&id, &hash, &size, &configXML, &createdAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return map[string]interface{}{ + "id": id, + "hash": hash, + "size": size, + "config_xml": configXML, + "created_at": createdAt, + }, nil +} + +// DeleteConfigBackup loescht ein einzelnes Backup +func (d *Database) DeleteConfigBackup(agentID string, backupID int64) (bool, error) { + res, err := d.db.Exec("DELETE FROM config_backups WHERE agent_id = ? AND id = ?", agentID, backupID) + if err != nil { + return false, err + } + rows, _ := res.RowsAffected() + return rows > 0, nil +} + // DeleteAgent - Agent und zugehoerige Daten loeschen func (d *Database) DeleteAgent(id string) (bool, error) { // Foreign Key Cascade aktivieren diff --git a/backend/ws/handler.go b/backend/ws/handler.go index 49e9e45..ea2d6d9 100644 --- a/backend/ws/handler.go +++ b/backend/ws/handler.go @@ -82,7 +82,7 @@ func (c *Connection) readPump() { }() // Read-Timeouts setzen - c.Conn.SetReadLimit(512 * 1024) // 512KB max message size + c.Conn.SetReadLimit(4 * 1024 * 1024) // 4MB max message size (fuer config.xml Backups) c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))