- 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
569 lines
16 KiB
Go
569 lines
16 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Handler struct {
|
|
client *Client
|
|
}
|
|
|
|
func NewHandler(client *Client) *Handler {
|
|
return &Handler{client: client}
|
|
}
|
|
|
|
func (h *Handler) HandleCommand(msg Message) {
|
|
log.Printf("Command erhalten: %s (ID: %s)", msg.Command, msg.ID)
|
|
|
|
var response Message
|
|
response.Type = "response"
|
|
response.ID = msg.ID
|
|
|
|
switch msg.Command {
|
|
case "exec":
|
|
response = h.handleExec(msg)
|
|
case "update":
|
|
response = h.handleUpdate(msg)
|
|
case "major_update":
|
|
response = h.handleMajorUpdate(msg)
|
|
case "update_check":
|
|
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":
|
|
h.handleTunnelDisconnect(msg)
|
|
return
|
|
case "tunnel_open":
|
|
response.Status = "ok"
|
|
response.Data = map[string]interface{}{"message": "tunnel_open deprecated, use tunnel_connect"}
|
|
case "tunnel_close":
|
|
response.Status = "ok"
|
|
default:
|
|
response.Status = "error"
|
|
response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command)
|
|
}
|
|
|
|
if err := h.client.SendMessage(response); err != nil {
|
|
log.Printf("Response senden fehlgeschlagen: %v", err)
|
|
}
|
|
}
|
|
|
|
// handleExec fuehrt einen beliebigen Befehl aus
|
|
func (h *Handler) handleExec(msg Message) Message {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
|
|
command, ok := msg.Params["command"].(string)
|
|
if !ok || command == "" {
|
|
response.Status = "error"
|
|
response.Error = "Parameter 'command' erforderlich"
|
|
return response
|
|
}
|
|
|
|
timeoutSec := 30
|
|
if t, ok := msg.Params["timeout"]; ok {
|
|
if ts, ok := t.(float64); ok {
|
|
timeoutSec = int(ts)
|
|
} else if ts, ok := t.(string); ok {
|
|
if parsed, err := strconv.Atoi(ts); err == nil {
|
|
timeoutSec = parsed
|
|
}
|
|
}
|
|
}
|
|
|
|
output, err := h.runCommand(command, timeoutSec)
|
|
|
|
if err != nil {
|
|
response.Status = "error"
|
|
response.Error = err.Error()
|
|
response.Data = map[string]interface{}{"output": output}
|
|
} else {
|
|
response.Status = "ok"
|
|
response.Data = map[string]interface{}{"output": output}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
// handleUpdateCheck prueft ob Updates verfuegbar sind
|
|
func (h *Handler) handleUpdateCheck(msg Message) Message {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
log.Println("Update-Check gestartet...")
|
|
|
|
result := map[string]interface{}{}
|
|
|
|
// 1. OPNsense Core Update pruefen
|
|
coreOutput, coreErr := h.runCommand("/usr/local/sbin/opnsense-update -c", 60)
|
|
if coreErr != nil {
|
|
// Exit 1 = Update verfuegbar ODER Reboot noetig
|
|
info := strings.TrimSpace(coreOutput)
|
|
result["core_update_available"] = true
|
|
result["core_update_info"] = info
|
|
} else {
|
|
result["core_update_available"] = false
|
|
result["core_update_info"] = "Kein Core-Update verfuegbar"
|
|
}
|
|
|
|
// Reboot-Check: Base/Kernel Version != installierte Version?
|
|
baseVer, _ := h.runCommand("cat /usr/local/opnsense/version/base", 5)
|
|
kernelVer, _ := h.runCommand("cat /usr/local/opnsense/version/kernel", 5)
|
|
coreJson, _ := h.runCommand("cat /usr/local/opnsense/version/core", 5)
|
|
baseVer = strings.TrimSpace(baseVer)
|
|
kernelVer = strings.TrimSpace(kernelVer)
|
|
|
|
rebootRequired := false
|
|
// Pruefen ob CORE_SERIES sich von Base/Kernel unterscheidet
|
|
if strings.Contains(coreJson, "CORE_NEXT") && baseVer != "" && kernelVer != "" {
|
|
// Einfacher Check: wenn base/kernel neuer als core → reboot noetig
|
|
coreVer := ""
|
|
for _, line := range strings.Split(coreJson, "\n") {
|
|
if strings.Contains(line, "CORE_PKGVERSION") {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
coreVer = strings.Trim(strings.TrimSpace(parts[1]), "\",")
|
|
}
|
|
}
|
|
}
|
|
if coreVer != "" && baseVer != "" && !strings.HasPrefix(baseVer, strings.Split(coreVer, ".")[0]+"."+strings.Split(coreVer, ".")[1]) {
|
|
// Base-Version Major.Minor unterscheidet sich von Core → Reboot
|
|
rebootRequired = true
|
|
}
|
|
}
|
|
result["reboot_required"] = rebootRequired
|
|
|
|
// 2. Paket-Updates pruefen
|
|
pkgOutput, _ := h.runCommand("/usr/sbin/pkg upgrade -n", 60)
|
|
pendingPackages := h.parsePkgUpgrade(pkgOutput)
|
|
result["package_update_available"] = len(pendingPackages) > 0
|
|
result["pending_packages"] = pendingPackages
|
|
result["pending_count"] = len(pendingPackages)
|
|
|
|
response.Status = "ok"
|
|
response.Data = result
|
|
log.Printf("Update-Check abgeschlossen: Core=%v, Packages=%d",
|
|
result["core_update_available"], len(pendingPackages))
|
|
return response
|
|
}
|
|
|
|
// handleUpdate fuehrt ein normales Update durch (Core + Packages)
|
|
func (h *Handler) handleUpdate(msg Message) Message {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
log.Println("Normales Update gestartet...")
|
|
|
|
reboot := false
|
|
if r, ok := msg.Params["reboot"].(bool); ok {
|
|
reboot = r
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"steps": []map[string]interface{}{},
|
|
}
|
|
var steps []map[string]interface{}
|
|
|
|
// Schritt 1: opnsense-update (Core + Firmware)
|
|
log.Println("Update Schritt 1: opnsense-update")
|
|
coreOutput, coreErr := h.runCommand("/usr/local/sbin/opnsense-update", 600)
|
|
step1 := map[string]interface{}{
|
|
"step": "opnsense-update",
|
|
"output": coreOutput,
|
|
"ok": coreErr == nil,
|
|
}
|
|
if coreErr != nil {
|
|
step1["error"] = coreErr.Error()
|
|
}
|
|
steps = append(steps, step1)
|
|
|
|
// Schritt 2: pkg upgrade -y (Paket-Updates)
|
|
log.Println("Update Schritt 2: pkg upgrade -y")
|
|
pkgOutput, pkgErr := h.runCommand("/usr/sbin/pkg upgrade -y", 600)
|
|
step2 := map[string]interface{}{
|
|
"step": "pkg upgrade",
|
|
"output": pkgOutput,
|
|
"ok": pkgErr == nil,
|
|
}
|
|
if pkgErr != nil {
|
|
step2["error"] = pkgErr.Error()
|
|
}
|
|
steps = append(steps, step2)
|
|
|
|
// Schritt 3: opnsense-update -c (Nachpruefung)
|
|
log.Println("Update Schritt 3: Nachpruefung")
|
|
checkOutput, checkErr := h.runCommand("/usr/local/sbin/opnsense-update -c", 60)
|
|
rebootRequired := checkErr != nil // Exit 1 nach Update = Reboot noetig
|
|
step3 := map[string]interface{}{
|
|
"step": "verify",
|
|
"output": checkOutput,
|
|
"reboot_required": rebootRequired,
|
|
}
|
|
steps = append(steps, step3)
|
|
|
|
result["steps"] = steps
|
|
result["reboot_required"] = rebootRequired
|
|
result["reboot_requested"] = reboot
|
|
|
|
// Gesamtstatus
|
|
if coreErr == nil && pkgErr == nil {
|
|
response.Status = "ok"
|
|
if rebootRequired {
|
|
result["message"] = "Update erfolgreich — Reboot erforderlich"
|
|
} else {
|
|
result["message"] = "Update erfolgreich abgeschlossen"
|
|
}
|
|
} else {
|
|
response.Status = "ok"
|
|
result["message"] = "Update mit Warnungen abgeschlossen"
|
|
}
|
|
|
|
// Reboot wenn gewuenscht
|
|
if reboot {
|
|
log.Println("Reboot nach Update angefordert...")
|
|
result["reboot"] = "Reboot in 5 Sekunden..."
|
|
response.Data = result
|
|
|
|
// Response erst senden, dann rebooten
|
|
go func() {
|
|
time.Sleep(5 * time.Second)
|
|
log.Println("Reboot wird ausgefuehrt...")
|
|
exec.Command("/sbin/shutdown", "-r", "now").Run()
|
|
}()
|
|
|
|
return response
|
|
}
|
|
|
|
response.Data = result
|
|
log.Println("Update abgeschlossen")
|
|
return response
|
|
}
|
|
|
|
// handleMajorUpdate fuehrt ein Major-Upgrade durch
|
|
// 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 {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
|
|
targetVersion, ok := msg.Params["version"].(string)
|
|
if !ok || targetVersion == "" {
|
|
response.Status = "error"
|
|
response.Error = "Parameter 'version' erforderlich (z.B. '26.1')"
|
|
return response
|
|
}
|
|
|
|
// 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, Phase %s gestartet...", targetVersion, phase)
|
|
|
|
result := map[string]interface{}{
|
|
"target_version": targetVersion,
|
|
"phase": phase,
|
|
}
|
|
var steps []map[string]interface{}
|
|
hasError := false
|
|
|
|
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 <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)
|
|
releaseOutput, releaseErr := h.runCommand(releaseCmd, 900)
|
|
step1 := map[string]interface{}{
|
|
"step": fmt.Sprintf("opnsense-update -r %s -bkp", targetVersion),
|
|
"output": releaseOutput,
|
|
"ok": releaseErr == nil,
|
|
}
|
|
if releaseErr != nil {
|
|
step1["error"] = releaseErr.Error()
|
|
hasError = true
|
|
}
|
|
steps = append(steps, step1)
|
|
|
|
result["steps"] = steps
|
|
result["reboot_required"] = true
|
|
result["reboot_requested"] = reboot
|
|
|
|
if !hasError {
|
|
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 Phase 1 auf %s mit Fehlern", targetVersion)
|
|
}
|
|
|
|
response.Status = "ok"
|
|
|
|
if reboot {
|
|
log.Println("Reboot nach Phase 1 angefordert...")
|
|
result["reboot"] = "Reboot in 5 Sekunden..."
|
|
response.Data = result
|
|
|
|
go func() {
|
|
time.Sleep(5 * time.Second)
|
|
log.Println("Reboot wird ausgefuehrt...")
|
|
exec.Command("/sbin/shutdown", "-r", "now").Run()
|
|
}()
|
|
|
|
return response
|
|
}
|
|
|
|
response.Data = result
|
|
log.Printf("Major-Update Phase 1 auf %s abgeschlossen (Reboot noch ausstehend)", targetVersion)
|
|
return response
|
|
}
|
|
|
|
// handleReboot fuehrt einen Reboot durch
|
|
func (h *Handler) handleReboot(msg Message) Message {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
|
|
delaySec := 5
|
|
if d, ok := msg.Params["delay"].(float64); ok && d > 0 {
|
|
delaySec = int(d)
|
|
}
|
|
|
|
log.Printf("Reboot angefordert (Verzoegerung: %ds)", delaySec)
|
|
|
|
response.Status = "ok"
|
|
response.Data = map[string]interface{}{
|
|
"message": fmt.Sprintf("Reboot in %d Sekunden", delaySec),
|
|
}
|
|
|
|
go func() {
|
|
time.Sleep(time.Duration(delaySec) * time.Second)
|
|
log.Println("Reboot wird ausgefuehrt...")
|
|
exec.Command("/sbin/shutdown", "-r", "now").Run()
|
|
}()
|
|
|
|
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 {
|
|
response := Message{Type: "response", ID: msg.ID}
|
|
|
|
sessionID, _ := msg.Params["session_id"].(string)
|
|
tunnelID, _ := msg.Params["tunnel_id"].(string)
|
|
targetHost, _ := msg.Params["target_host"].(string)
|
|
targetPortFloat, _ := msg.Params["target_port"].(float64)
|
|
targetPort := int(targetPortFloat)
|
|
|
|
if sessionID == "" || targetHost == "" || targetPort <= 0 {
|
|
response.Status = "error"
|
|
response.Error = "session_id, target_host und target_port erforderlich"
|
|
return response
|
|
}
|
|
|
|
if err := h.client.tunnelManager.ConnectSession(sessionID, tunnelID, targetHost, targetPort); err != nil {
|
|
response.Status = "error"
|
|
response.Error = fmt.Sprintf("Session-Connect fehlgeschlagen: %v", err)
|
|
return response
|
|
}
|
|
|
|
response.Status = "ok"
|
|
response.Data = map[string]interface{}{"session_id": sessionID}
|
|
return response
|
|
}
|
|
|
|
func (h *Handler) handleTunnelDisconnect(msg Message) {
|
|
sessionID, _ := msg.Params["session_id"].(string)
|
|
if sessionID != "" {
|
|
h.client.tunnelManager.DisconnectSession(sessionID)
|
|
}
|
|
}
|
|
|
|
// --- Helper ---
|
|
|
|
// runCommand fuehrt einen Shell-Befehl mit Timeout aus
|
|
func (h *Handler) runCommand(command string, timeoutSec int) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
|
|
output, err := cmd.CombinedOutput()
|
|
return string(output), err
|
|
}
|
|
|
|
// parsePkgUpgrade parst die Ausgabe von "pkg upgrade -n"
|
|
func (h *Handler) parsePkgUpgrade(output string) []map[string]string {
|
|
var packages []map[string]string
|
|
|
|
inUpgradeBlock := false
|
|
for _, line := range strings.Split(output, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if strings.Contains(trimmed, "packages to be UPGRADED") ||
|
|
strings.Contains(trimmed, "will be affected") {
|
|
inUpgradeBlock = true
|
|
continue
|
|
}
|
|
|
|
if inUpgradeBlock && strings.Contains(trimmed, "->") {
|
|
parts := strings.SplitN(trimmed, ":", 2)
|
|
if len(parts) == 2 {
|
|
pkg := strings.TrimSpace(parts[0])
|
|
versions := strings.TrimSpace(parts[1])
|
|
|
|
arrowParts := strings.SplitN(versions, "->", 2)
|
|
if len(arrowParts) == 2 {
|
|
packages = append(packages, map[string]string{
|
|
"package": pkg,
|
|
"current_version": strings.TrimSpace(arrowParts[0]),
|
|
"new_version": strings.TrimSpace(arrowParts[1]),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if inUpgradeBlock && (trimmed == "" || strings.HasPrefix(trimmed, "Number of")) {
|
|
if !strings.HasPrefix(trimmed, "Number of") {
|
|
inUpgradeBlock = false
|
|
}
|
|
}
|
|
}
|
|
|
|
return packages
|
|
}
|
|
|
|
// SendSessionData schickt Tunnel-Daten als Binary-Message zurueck zum Backend
|
|
func (h *Handler) SendSessionData(sessionID string, data []byte) error {
|
|
idBytes := []byte(sessionID)
|
|
if len(idBytes) > 255 {
|
|
return fmt.Errorf("session_id zu lang")
|
|
}
|
|
|
|
message := make([]byte, 1+len(idBytes)+len(data))
|
|
message[0] = byte(len(idBytes))
|
|
copy(message[1:], idBytes)
|
|
copy(message[1+len(idBytes):], data)
|
|
|
|
return h.client.SendBinaryData(message)
|
|
}
|