syncova-backup/apps/api/internal/httpapi/verification_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

460 lines
18 KiB
Go

package httpapi
import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/auth"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/verification"
)
// verificationHandler bedient die Pruefung (SYNCOVA_API.md §14).
type verificationHandler struct {
// verificationStore ist die Datenzugriffsschicht der Pruefauftraege.
verificationStore *verification.Store
// jobStore liefert Backups und Repositories.
jobStore *jobs.PostgresStore
// auditRecorder protokolliert ausgeloeste Pruefungen.
auditRecorder audit.Recorder
// logger protokolliert technische Fehler.
logger *slog.Logger
}
// verificationRequest ist der Rumpf von POST /verification.
type verificationRequest struct {
// BackupID ist das zu pruefende Backup.
BackupID uuid.UUID `json:"backup_id"`
// VerificationType ist die Art der Pruefung.
VerificationType string `json:"verification_type"`
}
// verificationResponse ist die Darstellung eines Pruefauftrags.
type verificationResponse struct {
// ID ist der oeffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// BackupID ist das gepruefte Backup.
BackupID uuid.UUID `json:"backup_id"`
// VerificationType ist die Art der Pruefung.
VerificationType string `json:"verification_type"`
// Status ist der Zustand.
Status string `json:"status"`
// Result ist das Ergebnis; leer solange nicht abgeschlossen.
Result string `json:"result,omitempty"`
// ChunksChecked ist die Zahl gepruefter Bloecke.
ChunksChecked int64 `json:"chunks_checked"`
// ChunksMissing ist die Zahl fehlender Bloecke.
ChunksMissing int64 `json:"chunks_missing"`
// ChunksCorrupted ist die Zahl beschaedigter Bloecke.
ChunksCorrupted int64 `json:"chunks_corrupted"`
// BytesRead ist die gelesene Datenmenge.
BytesRead int64 `json:"bytes_read"`
// StartedAt ist der Beginn in UTC.
StartedAt *time.Time `json:"started_at,omitempty"`
// CompletedAt ist das Ende in UTC.
CompletedAt *time.Time `json:"completed_at,omitempty"`
// DurationSeconds ist die Dauer in Sekunden.
DurationSeconds float64 `json:"duration_seconds,omitempty"`
// ErrorMessage ist die verstaendliche Fehlermeldung.
ErrorMessage string `json:"error_message,omitempty"`
// Summary fasst das Ergebnis in einem Satz zusammen.
//
// Die Zusammenfassung sagt ausdruecklich, was **nicht** geprueft wurde: Eine
// Manifestpruefung ohne diesen Zusatz liesse sich fuer einen Nachweis der
// Wiederherstellbarkeit halten, der sie nicht ist.
Summary string `json:"summary,omitempty"`
// CorrelationID verbindet den Auftrag mit seinen Protokollzeilen.
CorrelationID uuid.UUID `json:"correlation_id"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// assuranceResponse ist die Bewertung eines Backups.
type assuranceResponse struct {
// BackupID ist das bewertete Backup.
BackupID uuid.UUID `json:"backup_id"`
// Classification ist die objektive Einstufung.
Classification string `json:"classification"`
// ClassificationDescription erklaert die Einstufung.
ClassificationDescription string `json:"classification_description"`
// Percentage ist die Bewertung in Prozent.
Percentage int `json:"percentage"`
// UnknownInputCount ist die Zahl ungemessener Eingangsgroessen.
UnknownInputCount int `json:"unknown_input_count"`
// IsTrustworthy meldet eine belastbare Bewertung.
IsTrustworthy bool `json:"is_trustworthy"`
// Summary fasst die Bewertung in einem Satz zusammen.
Summary string `json:"summary"`
// MissingMeasurements nennt die fehlenden Messungen als Handlungsanweisung.
MissingMeasurements []string `json:"missing_measurements"`
// Inputs sind die einzelnen Eingangsgroessen.
Inputs []verification.ScoreInput `json:"inputs"`
// LastVerifiedAt ist die letzte Integritaetspruefung in UTC.
LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"`
// LastRestoreTestAt ist der letzte Wiederherstellungstest in UTC.
LastRestoreTestAt *time.Time `json:"last_restore_test_at,omitempty"`
}
// handleCreateVerification bedient POST /verification.
//
// Die Pruefung wird eingereiht, nicht ausgefuehrt: Die Pruefschleife holt sie im
// naechsten Durchgang. Deshalb 202 statt 201 — das Ergebnis steht noch aus.
func (handler *verificationHandler) handleCreateVerification(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
var verificationPayload verificationRequest
if decodeError := decodeJSONBody(request, &verificationPayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
requestedType, typeError := parseVerificationType(verificationPayload.VerificationType)
if typeError != nil {
WriteError(responseWriter, request, requestLogger, typeError)
return
}
// Ein Wiederherstellungstest liest das gesamte Backup und schreibt es
// versuchsweise zurueck. Er belastet Datentraeger und Leitung erheblich und
// haengt deshalb an einem eigenen Recht.
if requestedType == verification.TypeRestoreTest && !userHasPermission(actingUser, "verification.restore_test") {
permissionError := NewValidationError(
"Fuer einen Wiederherstellungstest fehlt die Berechtigung verification.restore_test.")
permissionError.Code = ErrorCodePermissionDenied
permissionError.StatusCode = http.StatusForbidden
WriteError(responseWriter, request, requestLogger, permissionError)
return
}
// Das Backup wird vor dem Einreihen aufgeloest: Eine Pruefung eines nicht
// vorhandenen Backups liefe erst in der Schleife auf, wo niemand die Antwort
// sieht.
backupRecord, backupError := handler.jobStore.GetBackup(request.Context(), verificationPayload.BackupID)
if backupError != nil {
if errors.Is(backupError, jobs.ErrBackupNotFound) {
WriteError(responseWriter, request, requestLogger,
NewNotFoundError("Das Backup wurde nicht gefunden."))
return
}
WriteError(responseWriter, request, requestLogger, NewInternalError(backupError))
return
}
createdJob, createError := handler.verificationStore.CreateJob(request.Context(),
verificationPayload.BackupID, requestedType, &actingUser.ID)
if createError != nil {
WriteError(responseWriter, request, requestLogger, translateVerificationError(createError))
return
}
handler.recordAudit(request, actingUser, audit.ActionVerificationRequested, verificationPayload.BackupID,
map[string]any{
"verification_id": createdJob.ID.String(),
"verification_type": string(requestedType),
"backup_in_repository": backupRecord.BackupIDInRepository,
})
WriteSuccess(responseWriter, request, http.StatusAccepted, buildVerificationResponse(createdJob))
}
// handleListVerifications bedient GET /verification.
func (handler *verificationHandler) handleListVerifications(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1)
requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50)
loadedJobs, totalCount, listError := handler.verificationStore.ListJobs(
request.Context(), requestedPage, requestedPageSize)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
jobResponses := make([]verificationResponse, 0, len(loadedJobs))
for jobIndex := range loadedJobs {
jobResponses = append(jobResponses, buildVerificationResponse(&loadedJobs[jobIndex]))
}
WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{
Page: requestedPage,
PageSize: requestedPageSize,
Total: int64(totalCount),
})
}
// handleGetVerification bedient GET /verification/{id}.
func (handler *verificationHandler) handleGetVerification(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
jobIdentifier, parseError := parseVerificationIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, buildVerificationResponse(loadedJob))
}
// handleGetVerificationResults bedient GET /verification/{id}/results.
//
// Getrennt vom Auftrag, weil der vollstaendige Bericht bei einem grossen Backup
// viele Befunde traegt. Eine Liste von Auftraegen bliebe damit nicht mehr
// ueberschaubar.
func (handler *verificationHandler) handleGetVerificationResults(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
jobIdentifier, parseError := parseVerificationIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
return
}
if loadedJob.Report == nil {
// Kein Bericht heisst: Die Pruefung ist nicht so weit gekommen. Ein leeres
// Ergebnis auszugeben liesse das wie ein sauberes Ergebnis aussehen.
WriteError(responseWriter, request, requestLogger, NewNotFoundError(
"Fuer diese Pruefung liegt kein Bericht vor. Sie laeuft noch oder konnte nicht durchgefuehrt werden."))
return
}
resultPayload := map[string]any{
"verification_id": loadedJob.ID,
"backup_id": loadedJob.BackupID,
"verification_type": string(loadedJob.VerificationType),
"result": string(loadedJob.Result),
"summary": loadedJob.Report.Summary(),
"report": loadedJob.Report,
}
if loadedJob.RestoreTestReport != nil {
resultPayload["restore_test"] = loadedJob.RestoreTestReport
}
WriteSuccess(responseWriter, request, http.StatusOK, resultPayload)
}
// handleCancelVerification bedient POST /verification/{id}/cancel.
func (handler *verificationHandler) handleCancelVerification(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
jobIdentifier, parseError := parseVerificationIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
existingJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
return
}
if cancelError := handler.verificationStore.CancelJob(request.Context(), jobIdentifier); cancelError != nil {
WriteError(responseWriter, request, requestLogger, translateVerificationError(cancelError))
return
}
handler.recordAudit(request, actingUser, audit.ActionVerificationCancelled, existingJob.BackupID,
map[string]any{
"verification_id": jobIdentifier.String(),
"verification_type": string(existingJob.VerificationType),
})
cancelledJob, _ := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
// Die abgebrochene Pruefung sagt nichts ueber das Backup. Das wird gesagt,
// damit niemand den Abbruch fuer ein Ergebnis haelt.
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
"verification": buildVerificationResponse(cancelledJob),
"message": "Die Pruefung wurde abgebrochen. Sie sagt damit nichts ueber den Zustand des Backups; " +
"die bisherige Einstufung bleibt unveraendert.",
})
}
// handleGetAssurance bedient GET /backups/{id}/assurance.
//
// Die Bewertung wird bei jedem Aufruf neu berechnet, nicht aus der Datenbank
// gelesen: Sie haengt am Alter der Messungen und veraltet damit von selbst. Ein
// gespeicherter Wert wuerde mit jedem Tag falscher, ohne dass sich etwas
// aendert — genau die stille Beschoenigung, die es hier nicht geben darf.
func (handler *verificationHandler) handleGetAssurance(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Backupkennung ist keine gueltige UUID."))
return
}
backupFacts, factsError := handler.verificationStore.BackupAssuranceFacts(request.Context(), backupIdentifier)
if factsError != nil {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Backup wurde nicht gefunden."))
return
}
classification := verification.Classify(backupFacts)
assuranceScore := verification.CalculateScore(backupFacts, time.Now())
// Die berechnete Bewertung wird fortgeschrieben, damit eine Uebersicht sie
// anzeigen kann, ohne sie fuer jede Zeile neu zu berechnen. Massgeblich
// bleibt die Berechnung — der gespeicherte Wert ist nur ihr Abbild.
if saveError := handler.verificationStore.SaveAssuranceScore(request.Context(),
backupIdentifier, assuranceScore); saveError != nil {
requestLogger.Warn("die bewertung konnte nicht gespeichert werden",
slog.String("backup", backupIdentifier.String()),
slog.String("grund", saveError.Error()))
}
WriteSuccess(responseWriter, request, http.StatusOK, assuranceResponse{
BackupID: backupIdentifier,
Classification: string(classification),
ClassificationDescription: classification.Describe(),
Percentage: assuranceScore.Percentage,
UnknownInputCount: assuranceScore.UnknownInputCount,
IsTrustworthy: assuranceScore.IsTrustworthy(),
Summary: assuranceScore.Summary(),
MissingMeasurements: assuranceScore.MissingMeasurements(),
Inputs: assuranceScore.Inputs,
LastVerifiedAt: backupFacts.LastVerifiedAt,
LastRestoreTestAt: backupFacts.LastRestoreTestAt,
})
}
// recordAudit schreibt ein Auditereignis.
func (handler *verificationHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, backupIdentifier uuid.UUID, auditDetails map[string]any) {
if handler.auditRecorder == nil {
return
}
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditAction,
EntityType: "backup",
EntityID: &backupIdentifier,
Result: audit.ResultSuccess,
IPAddress: clientIPAddress(request),
UserAgent: request.UserAgent(),
CorrelationID: correlationID,
Details: auditDetails,
})
if recordError != nil {
logging.WithContext(request.Context(), handler.logger).Error(
"das auditereignis konnte nicht geschrieben werden",
slog.String("aktion", string(auditAction)),
slog.String("grund", recordError.Error()))
}
}
// buildVerificationResponse wandelt einen Auftrag in seine Darstellung.
func buildVerificationResponse(sourceJob *verification.Job) verificationResponse {
builtResponse := verificationResponse{
ID: sourceJob.ID,
BackupID: sourceJob.BackupID,
VerificationType: string(sourceJob.VerificationType),
Status: string(sourceJob.Status),
Result: string(sourceJob.Result),
ChunksChecked: sourceJob.ChunksChecked,
ChunksMissing: sourceJob.ChunksMissing,
ChunksCorrupted: sourceJob.ChunksCorrupted,
BytesRead: sourceJob.BytesRead,
StartedAt: sourceJob.StartedAt,
CompletedAt: sourceJob.CompletedAt,
ErrorMessage: sourceJob.ErrorMessage,
CorrelationID: sourceJob.CorrelationID,
CreatedAt: sourceJob.CreatedAt,
}
if sourceJob.Report != nil {
builtResponse.Summary = sourceJob.Report.Summary()
}
if sourceJob.StartedAt != nil && sourceJob.CompletedAt != nil {
builtResponse.DurationSeconds = sourceJob.CompletedAt.Sub(*sourceJob.StartedAt).Seconds()
}
return builtResponse
}
// parseVerificationType prueft die angeforderte Pruefart.
func parseVerificationType(requestedType string) (verification.VerificationType, *APIError) {
switch verification.VerificationType(requestedType) {
case verification.TypeManifest, verification.TypeChunkPresence,
verification.TypeChunkIntegrity, verification.TypeChain, verification.TypeRestoreTest:
return verification.VerificationType(requestedType), nil
case "":
// Ohne Angabe wird die Blockpruefung gewaehlt: Sie ist die schwaechste
// Pruefung, die ueberhaupt etwas ueber die Daten aussagt. Die
// Manifestpruefung als Vorgabe waere bequem und wertlos.
return verification.TypeChunkIntegrity, nil
default:
return "", NewValidationError(
"Die Pruefart ist unbekannt. Zulaessig sind manifest, chunk_presence, " +
"chunk_integrity, chain und restore_test.")
}
}
// parseVerificationIdentifier liest die Auftragskennung aus dem Pfad.
func parseVerificationIdentifier(request *http.Request) (uuid.UUID, *APIError) {
jobIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
return uuid.Nil, NewBadRequestError("Die Pruefkennung ist keine gueltige UUID.")
}
return jobIdentifier, nil
}
// translateVerificationError bildet Fehler der Fachschicht auf API-Fehler ab.
func translateVerificationError(occurredError error) *APIError {
switch {
case errors.Is(occurredError, verification.ErrJobNotFound):
return NewNotFoundError("Der Pruefauftrag wurde nicht gefunden.")
case errors.Is(occurredError, verification.ErrBackupBusy):
// 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, das Backup
// wird nur bereits geprueft.
conflictError := NewValidationError(
"Dieses Backup wird bereits geprueft. Warten Sie das Ergebnis ab.")
conflictError.Code = ErrorCodeConflict
conflictError.StatusCode = http.StatusConflict
return conflictError
default:
return NewInternalError(occurredError)
}
}