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
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user