syncova-backup/apps/api/internal/httpapi/hypervisor_handler.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

355 lines
12 KiB
Go

package httpapi
import (
"errors"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/hypervisor"
"github.com/syncova/syncova/packages/platform/logging"
)
// hypervisorHandler bedient die Endpunkte der Virtualisierungsumgebungen.
type hypervisorHandler struct {
// clusterStore ist die Datenzugriffsschicht der Verbünde.
clusterStore *hypervisor.Store
// auditRecorder protokolliert die verändernden Zugriffe.
auditRecorder audit.Recorder
// logger protokolliert technische Fehler.
logger *slog.Logger
}
// handleListClusters bedient GET /proxmox/clusters.
func (handler *hypervisorHandler) handleListClusters(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterList, listError := handler.clusterStore.ListClusters(request.Context())
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, clusterList)
}
// handleCreateCluster bedient POST /proxmox/clusters.
func (handler *hypervisorHandler) handleCreateCluster(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
var clusterInput hypervisor.ClusterInput
if decodeError := decodeJSONBody(request, &clusterInput); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
createdCluster, createError := handler.clusterStore.CreateCluster(request.Context(), clusterInput)
if createError != nil {
// Eine ungültige Eingabe ist kein Serverfehler. Die Meldung wird
// wörtlich weitergereicht, weil sie erklärt, was fehlt — eine
// allgemeine „Eingabe ungültig" ließe den Betreiber raten.
WriteError(responseWriter, request, requestLogger, NewValidationError(createError.Error()))
return
}
// Ein neuer Verbund bedeutet neue Zugangsdaten im System — das gehört ins
// Protokoll, unabhängig davon, ob es später jemanden interessiert.
handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterCreated,
createdCluster.ID, map[string]any{
"name": createdCluster.Name,
"api_endpoint": createdCluster.APIEndpoint,
"archive_transport": string(createdCluster.ArchiveTransport),
})
WriteSuccess(responseWriter, request, http.StatusCreated, createdCluster)
}
// handleGetCluster bedient GET /proxmox/clusters/{id}.
func (handler *hypervisorHandler) handleGetCluster(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
foundCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier)
if errors.Is(readError, hypervisor.ErrClusterNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
return
}
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, foundCluster)
}
// handleTestConnection bedient POST /proxmox/clusters/{id}/test.
//
// Die Prüfung verändert nichts am Verbund und ist der erste Schritt nach dem
// Einrichten: Sie sagt, ob Anmeldung und Zertifikatsbindung stimmen, bevor um
// zwei Uhr nachts eine Sicherung daran scheitert.
func (handler *hypervisorHandler) handleTestConnection(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
connectionError := handler.clusterStore.TestConnection(request.Context(), clusterIdentifier, handler.logger)
if errors.Is(connectionError, hypervisor.ErrClusterNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
return
}
// Der Zustand nach der Prüfung ist die eigentliche Antwort — auch im
// Fehlerfall. Ein 500 verschwiege, dass die Prüfung ordnungsgemäß gelaufen
// ist und ein Ergebnis hat.
updatedCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
responsePayload := map[string]any{
"cluster_id": clusterIdentifier,
"status": updatedCluster.Status,
"reachable": connectionError == nil,
}
if connectionError != nil {
responsePayload["error"] = connectionError.Error()
}
WriteSuccess(responseWriter, request, http.StatusOK, responsePayload)
}
// handleDiscover bedient POST /proxmox/clusters/{id}/discover.
func (handler *hypervisorHandler) handleDiscover(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
discoveryResult, discoveryError := handler.clusterStore.Discover(request.Context(),
clusterIdentifier, handler.logger)
if errors.Is(discoveryError, hypervisor.ErrClusterNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
return
}
if discoveryError != nil {
// Ein nicht erreichbarer Verbund ist kein Serverfehler, sondern eine
// Auskunft über die Anlage. 503 statt 500, damit die Oberfläche den
// Unterschied anzeigen kann.
WriteError(responseWriter, request, requestLogger,
NewServiceUnavailableError("Die Bestandsaufnahme schlug fehl: "+discoveryError.Error()))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, discoveryResult)
}
// handleDeleteCluster bedient DELETE /proxmox/clusters/{id}.
func (handler *hypervisorHandler) handleDeleteCluster(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
deleteError := handler.clusterStore.DeleteCluster(request.Context(), clusterIdentifier)
if errors.Is(deleteError, hypervisor.ErrClusterNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
return
}
// Ein Verbund, auf den noch Sicherungsquellen verweisen, wird nicht
// entfernt: Die Aufträge verlören ihre Grundlage, und zwar stillschweigend
// bis zum nächsten Lauf.
if errors.Is(deleteError, hypervisor.ErrClusterInUse) {
WriteError(responseWriter, request, requestLogger, &APIError{
StatusCode: http.StatusConflict,
Code: ErrorCodeConflict,
Message: deleteError.Error(),
})
return
}
if deleteError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(deleteError))
return
}
handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterDeleted,
clusterIdentifier, nil)
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"deleted": true})
}
// handleListVirtualMachines bedient GET /virtual-machines.
func (handler *hypervisorHandler) handleListVirtualMachines(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
var clusterFilter *uuid.UUID
if rawFilter := request.URL.Query().Get("cluster_id"); rawFilter != "" {
parsedFilter, parseError := uuid.Parse(rawFilter)
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
clusterFilter = &parsedFilter
}
machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), clusterFilter)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, machineList)
}
// recordAudit schreibt einen Eintrag ins Auditprotokoll.
//
// Ein fehlgeschlagener Eintrag wird protokolliert, aber nicht geworfen: Die
// Handlung ist geschehen, und ein Fehler an dieser Stelle machte sie nicht
// ungeschehen — er verschwiege sie nur zusätzlich.
func (handler *hypervisorHandler) recordAudit(request *http.Request, actingUserID uuid.UUID,
actingUsername string, auditAction audit.Action, entityIdentifier uuid.UUID,
auditDetails map[string]any) {
if handler.auditRecorder == nil {
return
}
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
UserID: &actingUserID,
ActorUsername: actingUsername,
Action: auditAction,
EntityType: "proxmox_cluster",
EntityID: &entityIdentifier,
Result: audit.ResultSuccess,
IPAddress: clientIPAddress(request),
UserAgent: request.UserAgent(),
CorrelationID: correlationIdentifier,
Details: auditDetails,
})
if recordError != nil {
logging.WithContext(request.Context(), handler.logger).Error(
"der audit-eintrag liess sich nicht schreiben",
slog.String("aktion", string(auditAction)),
slog.String("grund", recordError.Error()))
}
}
// handleListHosts bedient GET /proxmox/clusters/{id}/hosts.
func (handler *hypervisorHandler) handleListHosts(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
hostList, listError := handler.clusterStore.ListHosts(request.Context(), clusterIdentifier)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, hostList)
}
// handleListClusterMachines bedient GET /proxmox/clusters/{id}/vms.
func (handler *hypervisorHandler) handleListClusterMachines(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
return
}
machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), &clusterIdentifier)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, machineList)
}
// handleGetVirtualMachine bedient GET /proxmox/vms/{id}.
func (handler *hypervisorHandler) handleGetVirtualMachine(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
machineIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Gasts ist ungültig."))
return
}
foundMachine, readError := handler.clusterStore.GetVirtualMachine(request.Context(), machineIdentifier)
if errors.Is(readError, hypervisor.ErrVirtualMachineNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Gast wurde im Bestand nicht gefunden."))
return
}
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, foundMachine)
}