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:
cynfo3000 2026-02-28 10:44:09 +01:00
parent 23c8f2cd66
commit e7beada405
7 changed files with 656 additions and 42 deletions

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
build/ build/
rmm-agent
rmm-backend
*.db *.db
*.log *.log
*.pid *.pid

View File

@ -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}/major-update` | Major-Upgrade auf Zielversion |
| POST | `/api/v1/agents/{id}/reboot` | Reboot ausfuehren | | 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 ### Tunnel-Management
| Methode | Endpoint | Beschreibung | | Methode | Endpoint | Beschreibung |
@ -208,6 +221,27 @@ curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X POST \
-d '{"command":"uptime","timeout":30}' -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 ### Update-Beispiele
```bash ```bash
@ -269,6 +303,9 @@ Major-Upgrades setzen `reboot_required: true` und `reboot` ist standardmaessig a
// Reboot // Reboot
{"type":"command","id":"cmd5","command":"reboot","params":{"delay":5}} {"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) // 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}} {"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 │ │ └── system.go # SystemData, alle Collector-Structs
│ ├── api/ │ ├── api/
│ │ ├── handlers.go # REST API Handler │ │ ├── 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 │ │ └── middleware.go # API-Key Auth, Logging
│ └── ws/ │ └── ws/
│ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps │ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps

View File

@ -2,8 +2,12 @@ package ws
import ( import (
"context" "context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@ -36,6 +40,8 @@ func (h *Handler) HandleCommand(msg Message) {
response = h.handleUpdateCheck(msg) response = h.handleUpdateCheck(msg)
case "reboot": case "reboot":
response = h.handleReboot(msg) response = h.handleReboot(msg)
case "backup":
response = h.handleBackup(msg)
case "tunnel_connect": case "tunnel_connect":
response = h.handleTunnelConnect(msg) response = h.handleTunnelConnect(msg)
case "tunnel_disconnect": case "tunnel_disconnect":
@ -243,7 +249,10 @@ func (h *Handler) handleUpdate(msg Message) Message {
} }
// handleMajorUpdate fuehrt ein Major-Upgrade durch // handleMajorUpdate fuehrt ein Major-Upgrade durch
// Ablauf: opnsense-update -r <ver> -bkp → pkg-static update -f → pkg-static upgrade -y → Reboot // Ablauf: Phase 1: opnsense-update -r <ver> -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 { func (h *Handler) handleMajorUpdate(msg Message) Message {
response := Message{Type: "response", ID: msg.ID} response := Message{Type: "response", ID: msg.ID}
@ -254,21 +263,100 @@ func (h *Handler) handleMajorUpdate(msg Message) Message {
return response return response
} }
reboot := true // Major-Update braucht immer Reboot, default an // Phase 2: Wenn phase=2, nur pkg upgrade ausfuehren (nach Reboot)
if r, ok := msg.Params["reboot"].(bool); ok { phase := "1"
reboot = r 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{}{ result := map[string]interface{}{
"target_version": targetVersion, "target_version": targetVersion,
"phase": phase,
} }
var steps []map[string]interface{} var steps []map[string]interface{}
hasError := false hasError := false
// Schritt 1: opnsense-update -r <version> -bkp (Release wechseln + Base + Kernel + Packages) if phase == "2" {
log.Printf("Schritt 1: opnsense-update -r %s -bkp", targetVersion) // 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 <ver> -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) releaseCmd := fmt.Sprintf("/usr/local/sbin/opnsense-update -r %s -bkp", targetVersion)
releaseOutput, releaseErr := h.runCommand(releaseCmd, 900) releaseOutput, releaseErr := h.runCommand(releaseCmd, 900)
step1 := map[string]interface{}{ step1 := map[string]interface{}{
@ -282,48 +370,20 @@ func (h *Handler) handleMajorUpdate(msg Message) Message {
} }
steps = append(steps, step1) 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["steps"] = steps
result["reboot_required"] = true result["reboot_required"] = true
result["reboot_requested"] = reboot result["reboot_requested"] = reboot
if !hasError { 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 { } 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" response.Status = "ok"
if reboot { if reboot {
log.Println("Reboot nach Major-Update angefordert...") log.Println("Reboot nach Phase 1 angefordert...")
result["reboot"] = "Reboot in 5 Sekunden..." result["reboot"] = "Reboot in 5 Sekunden..."
response.Data = result response.Data = result
@ -337,7 +397,7 @@ func (h *Handler) handleMajorUpdate(msg Message) Message {
} }
response.Data = result 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 return response
} }
@ -366,6 +426,44 @@ func (h *Handler) handleReboot(msg Message) Message {
return response 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 --- // --- Tunnel Handlers ---
func (h *Handler) handleTunnelConnect(msg Message) Message { func (h *Handler) handleTunnelConnect(msg Message) Message {

335
backend/api/backup.go Normal file
View File

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

View File

@ -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("PUT /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub))
mux.HandleFunc("DELETE /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 // WebSocket-Endpoint
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub)) 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 { var req struct {
Version string `json:"version"` Version string `json:"version"`
Reboot bool `json:"reboot"` Reboot bool `json:"reboot"`
Phase string `json:"phase"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Version == "" { 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 return
} }
@ -222,6 +230,9 @@ func (h *Handler) agentMajorUpdate(hub *ws.Hub) http.HandlerFunc {
"version": req.Version, "version": req.Version,
"reboot": req.Reboot, "reboot": req.Reboot,
} }
if req.Phase != "" {
params["phase"] = req.Phase
}
h.sendCommandAndWait(hub, agentID, "major_update", params, 1200, w) h.sendCommandAndWait(hub, agentID, "major_update", params, 1200, w)
} }

View File

@ -46,6 +46,19 @@ func (d *Database) migrate() error {
last_heartbeat TEXT 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 ( CREATE TABLE IF NOT EXISTS system_data (
agent_id TEXT PRIMARY KEY, agent_id TEXT PRIMARY KEY,
data_json TEXT NOT NULL, data_json TEXT NOT NULL,
@ -219,6 +232,123 @@ func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) {
return &data, nil 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 // DeleteAgent - Agent und zugehoerige Daten loeschen
func (d *Database) DeleteAgent(id string) (bool, error) { func (d *Database) DeleteAgent(id string) (bool, error) {
// Foreign Key Cascade aktivieren // Foreign Key Cascade aktivieren

View File

@ -82,7 +82,7 @@ func (c *Connection) readPump() {
}() }()
// Read-Timeouts setzen // 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.SetReadDeadline(time.Now().Add(60 * time.Second))
c.Conn.SetPongHandler(func(string) error { c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))