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

482 lines
18 KiB
Go

package httpapi
import (
"context"
"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/repository"
)
// repositoryHandler bedient die Repository-Endpunkte (SYNCOVA_API.md §8).
//
// Bis Phase 22 gab es hier nur eine Liste: Repositories entstanden über
// `syncova-repo create` und wurden **von Hand in die Datenbank eingetragen**.
// Das fiel erst beim vollständigen Durchlauf auf — die Anlage ließ sich über
// ihre eigene API nicht in Betrieb nehmen.
type repositoryHandler struct {
// store ist die Datenzugriffsschicht.
store *jobs.PostgresStore
// auditRecorder protokolliert die verändernden Zugriffe.
auditRecorder audit.Recorder
// logger protokolliert technische Fehler.
logger *slog.Logger
}
// registerRepositoryRequest ist der Rumpf von POST /repositories.
type registerRepositoryRequest struct {
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// Location ist der Pfad der Ablage.
Location string `json:"location"`
// RepositoryType benennt die Ablageart; leer bedeutet "local".
RepositoryType string `json:"repository_type,omitempty"`
}
// updateRepositoryRequest ist der Rumpf von PATCH /repositories/{id}.
type updateRepositoryRequest struct {
// Status ist der neue Betriebszustand.
Status string `json:"status"`
}
// repositoryCheckResponse ist die Antwort der Prüfendpunkte.
type repositoryCheckResponse struct {
// RepositoryID ist das geprüfte Repository.
RepositoryID uuid.UUID `json:"repository_id"`
// Reachable meldet, ob das Repository geöffnet werden konnte.
Reachable bool `json:"reachable"`
// RepositoryUUID ist die im Repository hinterlegte Kennung.
RepositoryUUID string `json:"repository_uuid,omitempty"`
// Error ist der Grund eines Fehlschlags.
Error string `json:"error,omitempty"`
// Details tragen das Ergebnis der jeweiligen Prüfung.
Details map[string]any `json:"details,omitempty"`
}
// handleRegisterRepository bedient POST /repositories.
//
// **Es wird nichts angelegt, sondern übernommen.** Ein Repository entsteht auf
// einem Datenträger — mit `syncova-repo create`, das Descriptor und
// Verzeichnisse schreibt und den gehärteten Modus setzt. Dieser Endpunkt öffnet
// das vorhandene Repository, liest seine Kennung aus dem Descriptor und trägt
// es ein.
//
// Ein Eintrag ohne Repository dahinter wäre ein Ziel, das erst um zwei Uhr
// nachts als nicht vorhanden auffällt.
func (handler *repositoryHandler) handleRegisterRepository(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
var registerPayload registerRepositoryRequest
if decodeError := decodeJSONBody(request, &registerPayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
if registerPayload.Name == "" || registerPayload.Location == "" {
WriteError(responseWriter, request, requestLogger,
NewValidationError("Name und Ort des Repositorys sind erforderlich."))
return
}
repositoryType := registerPayload.RepositoryType
if repositoryType == "" {
repositoryType = "local"
}
// Erst nachsehen, dann eintragen. Der Descriptor sagt, ob dort überhaupt
// ein Repository liegt und welche Kennung es trägt.
inspectedRepository, inspectError := openRepositoryForInspection(request.Context(),
registerPayload.Location, handler.logger)
if inspectError != nil {
WriteError(responseWriter, request, requestLogger, NewValidationError(
"Unter "+registerPayload.Location+" liegt kein lesbares Repository: "+inspectError.Error()+
". Legen Sie es zuerst mit 'syncova-repo create' an."))
return
}
repositoryDescriptor := inspectedRepository.Descriptor()
if closeError := inspectedRepository.Close(); closeError != nil {
requestLogger.Warn("das geprüfte repository liess sich nicht schliessen",
slog.String("grund", closeError.Error()))
}
registeredRepository, registerError := handler.store.RegisterRepository(request.Context(),
jobs.RepositoryRegistration{
Name: registerPayload.Name,
RepositoryType: repositoryType,
Location: registerPayload.Location,
Status: jobs.RepositoryStatusActive,
Hardened: repositoryDescriptor.Immutable,
RepositoryUUID: repositoryDescriptor.RepositoryID,
})
if errors.Is(registerError, jobs.ErrRepositoryNameTaken) ||
errors.Is(registerError, jobs.ErrRepositoryLocationTaken) {
WriteError(responseWriter, request, requestLogger, &APIError{
StatusCode: http.StatusConflict,
Code: ErrorCodeConflict,
Message: registerError.Error(),
})
return
}
if registerError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(registerError))
return
}
handler.recordAudit(request, actingUser, audit.ActionRepositoryRegistered, registeredRepository.ID,
map[string]any{
"name": registeredRepository.Name,
"location": registeredRepository.Location,
"hardened": registeredRepository.Hardened,
})
WriteSuccess(responseWriter, request, http.StatusCreated, buildRepositoryResponse(*registeredRepository))
}
// handleGetRepository bedient GET /repositories/{id}.
func (handler *repositoryHandler) handleGetRepository(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
return
}
foundRepository, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
return
}
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*foundRepository))
}
// handleUpdateRepository bedient PATCH /repositories/{id}.
//
// Änderbar ist ausschließlich der Betriebszustand. Ort und Name gehören zum
// Repository selbst; sie hier zu ändern hieße, den Eintrag von der Ablage zu
// lösen, auf die er zeigt.
func (handler *repositoryHandler) handleUpdateRepository(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
return
}
var updatePayload updateRepositoryRequest
if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
newStatus := jobs.RepositoryStatus(updatePayload.Status)
if !isKnownRepositoryStatus(newStatus) {
WriteError(responseWriter, request, requestLogger, NewValidationError(
"Zulässige Zustände sind active, read_only, unavailable und maintenance."))
return
}
updatedRepository, updateError := handler.store.UpdateRepositoryStatus(request.Context(),
repositoryIdentifier, newStatus)
if errors.Is(updateError, jobs.ErrRepositoryNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
return
}
if updateError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(updateError))
return
}
// Ein Ziel aus dem Betrieb zu nehmen, hält Sicherungen an. Das gehört ins
// Protokoll: Sonst sucht später jemand den Grund für ausbleibende Backups.
handler.recordAudit(request, actingUser, audit.ActionRepositoryStatusChanged, updatedRepository.ID,
map[string]any{"status": string(newStatus)})
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*updatedRepository))
}
// handleTestRepository bedient POST /repositories/{id}/test.
//
// Die schnelle Prüfung: Lässt sich das Repository öffnen und ist es dasselbe
// wie beim Eintragen? Ein Ziel, dessen Kennung sich geändert hat, ist ein
// anderes Repository am selben Pfad — und die dort vermerkten Backups sind
// nicht die, die man sucht.
func (handler *repositoryHandler) handleTestRepository(responseWriter http.ResponseWriter, request *http.Request) {
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error) {
repositoryDescriptor := openedRepository.Descriptor()
checkDetails := map[string]any{
"repository_uuid": repositoryDescriptor.RepositoryID,
"format_version": repositoryDescriptor.FormatVersion,
"hardened": repositoryDescriptor.Immutable,
}
if storedRecord.RepositoryUUID != "" && storedRecord.RepositoryUUID != repositoryDescriptor.RepositoryID {
checkDetails["identity_mismatch"] = true
return checkDetails, errors.New("das repository unter diesem pfad ist nicht mehr dasselbe " +
"(erwartet " + storedRecord.RepositoryUUID + ", vorgefunden " + repositoryDescriptor.RepositoryID + ")")
}
return checkDetails, nil
})
}
// handleRepositoryHealth bedient POST /repositories/{id}/health-check.
func (handler *repositoryHandler) handleRepositoryHealth(responseWriter http.ResponseWriter, request *http.Request) {
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
healthReport, healthError := openedRepository.Health(checkContext)
if healthError != nil {
return nil, healthError
}
return map[string]any{
"status": string(healthReport.Status),
"message": healthReport.Message,
"recommended_action": healthReport.RecommendedAction,
"backup_count": healthReport.BackupCount,
"capacity_bytes": healthReport.CapacityBytes,
"used_bytes": healthReport.UsedBytes,
"free_bytes": healthReport.FreeBytes,
"used_percentage": healthReport.UsedPercentage(),
"latency_ms": healthReport.LatencyMilliseconds,
}, nil
})
}
// handleRepositoryIntegrityScan bedient POST /repositories/{id}/integrity-scan.
//
// Der vollständige Lauf liest jeden Block und prüft ihn gegen seine Prüfsumme.
// Er läuft synchron: Ein Endpunkt, der sofort „gestartet" meldet und das
// Ergebnis nirgends hinterlegt, wäre ein Prüfwerkzeug ohne Prüfergebnis.
func (handler *repositoryHandler) handleRepositoryIntegrityScan(responseWriter http.ResponseWriter, request *http.Request) {
deepScan := request.URL.Query().Get("deep") != "false"
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
scanReport, scanError := openedRepository.Scan(checkContext, repository.ScanOptions{
VerifyChunkContents: deepScan,
})
if scanError != nil {
return nil, scanError
}
scanDetails := map[string]any{
"verified_chunk_contents": scanReport.VerifiedChunkContents,
"backups_checked": scanReport.BackupsChecked,
"backups_healthy": scanReport.BackupsHealthy,
"chunks_checked": scanReport.ChunksChecked,
"missing_chunks": scanReport.MissingChunks,
"corrupted_chunks": scanReport.CorruptedChunks,
"orphaned_chunks": scanReport.OrphanedChunks,
"healthy": scanReport.IsHealthy(),
"summary": scanReport.Summary(),
"affected_backup_ids": scanReport.AffectedBackupIDs,
}
// Ein Befund ist **kein** Fehler des Endpunkts: Die Prüfung ist
// ordnungsgemäß gelaufen und hat ein Ergebnis. Sie als Fehler zu melden
// verwechselte „die Prüfung schlug fehl" mit „das Repository ist
// beschädigt" — zwei völlig verschiedene Lagen.
return scanDetails, nil
})
}
// handleRebuildCatalog bedient POST /repositories/{id}/rebuild-catalog.
//
// Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Genau
// deshalb lässt er sich jederzeit neu bauen — und genau deshalb ist der
// Wiederaufbau harmlos.
func (handler *repositoryHandler) handleRebuildCatalog(responseWriter http.ResponseWriter, request *http.Request) {
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(checkContext)
if rebuildError != nil {
return nil, rebuildError
}
return map[string]any{"backups_in_catalog": len(rebuiltCatalog.Entries)}, nil
})
}
// repositoryCheckFunction führt eine Prüfung auf einem geöffneten Repository aus.
type repositoryCheckFunction func(checkContext context.Context,
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error)
// runRepositoryCheck öffnet das Repository und führt eine Prüfung aus.
//
// Der gemeinsame Rahmen aller vier Prüfendpunkte: Kennung lesen, Datensatz
// holen, Repository öffnen, prüfen, schließen. Ohne ihn stünde derselbe Ablauf
// viermal da, und beim vierten vergisst jemand das Schließen.
func (handler *repositoryHandler) runRepositoryCheck(responseWriter http.ResponseWriter,
request *http.Request, checkFunction repositoryCheckFunction) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
return
}
storedRecord, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
return
}
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
openedRepository, openError := openRepositoryForInspection(request.Context(),
storedRecord.Location, handler.logger)
if openError != nil {
// Ein nicht erreichbares Repository ist eine Auskunft über die Anlage,
// kein Serverfehler. Die Antwort trägt sie in der Hülle, damit die
// Oberfläche sie anzeigen kann.
WriteSuccess(responseWriter, request, http.StatusOK, repositoryCheckResponse{
RepositoryID: repositoryIdentifier,
Reachable: false,
Error: openError.Error(),
})
return
}
defer func() {
if closeError := openedRepository.Close(); closeError != nil {
requestLogger.Warn("das repository liess sich nicht schliessen",
slog.String("grund", closeError.Error()))
}
}()
checkDetails, checkError := checkFunction(request.Context(), openedRepository, storedRecord)
checkResponse := repositoryCheckResponse{
RepositoryID: repositoryIdentifier,
Reachable: checkError == nil,
RepositoryUUID: openedRepository.Descriptor().RepositoryID,
Details: checkDetails,
}
if checkError != nil {
checkResponse.Error = checkError.Error()
}
WriteSuccess(responseWriter, request, http.StatusOK, checkResponse)
}
// openRepositoryForInspection öffnet ein Repository schreibgeschützt.
//
// Schreibgeschützt, weil jede Prüfung nur liest — und weil ein Prüfaufruf sonst
// die Schreibsperre einer laufenden Sicherung bräuchte und daran wartete
// (dieselbe Entscheidung wie bei der Wiederherstellung in Phase 9).
func openRepositoryForInspection(openContext context.Context, repositoryLocation string,
baseLogger *slog.Logger) (*repository.LocalRepository, error) {
inspectionContext, cancelInspection := context.WithTimeout(openContext, 30*time.Second)
defer cancelInspection()
return repository.Open(inspectionContext, repositoryLocation,
repository.OpenOptions{ReadOnly: true}, baseLogger)
}
// isKnownRepositoryStatus prüft einen Zustandswert.
func isKnownRepositoryStatus(candidateStatus jobs.RepositoryStatus) bool {
switch candidateStatus {
case jobs.RepositoryStatusActive, jobs.RepositoryStatusReadOnly,
jobs.RepositoryStatusUnavailable, jobs.RepositoryStatusMaintenance:
return true
default:
return false
}
}
// buildRepositoryResponse formt die Antwort eines Repositorys.
func buildRepositoryResponse(storedRepository jobs.Repository) repositoryResponse {
return repositoryResponse{
ID: storedRepository.ID,
Name: storedRepository.Name,
RepositoryType: storedRepository.RepositoryType,
Location: storedRepository.Location,
Status: string(storedRepository.Status),
AcceptsBackups: storedRepository.Status.AcceptsWrites(),
Hardened: storedRepository.Hardened,
CreatedAt: storedRepository.CreatedAt,
}
}
// recordAudit schreibt einen Eintrag ins Auditprotokoll.
func (handler *repositoryHandler) recordAudit(request *http.Request, actingUser auth.User,
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: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditAction,
EntityType: "repository",
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()))
}
}