rmm2/backend/api/backup.go
cynfo3000 e7beada405 Config-Backup Feature: Agent liest config.xml, Backend speichert versioniert mit Deduplizierung
- Agent: neuer 'backup' Command (SHA256 Hash, Base64 Transfer)
- Backend: config_backups Tabelle in SQLite
- API: trigger, list, download (JSON/XML), delete, diff
- Deduplizierung: gleicher Hash = kein neuer Eintrag
- WebSocket ReadLimit auf 4MB erhoeht (grosse config.xml)
- README aktualisiert mit Backup-Docs
2026-02-28 10:44:09 +01:00

336 lines
9.2 KiB
Go

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")
}