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>
223 lines
7.7 KiB
Go
223 lines
7.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/reports"
|
|
)
|
|
|
|
// reportHandler bedient die Berichte (SYNCOVA_API.md §21).
|
|
type reportHandler struct {
|
|
// reportGenerator erzeugt die Berichte.
|
|
reportGenerator *reports.Generator
|
|
// auditRecorder protokolliert die Ausgabe eines Berichts.
|
|
auditRecorder audit.Recorder
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// reportCatalogEntry ist ein Eintrag des Berichtskatalogs.
|
|
type reportCatalogEntry struct {
|
|
// Type ist der maschinenlesbare Bezeichner.
|
|
Type reports.ReportType `json:"type"`
|
|
// Title ist die Bezeichnung.
|
|
Title string `json:"title"`
|
|
// Description erklaert, welche Frage der Bericht beantwortet.
|
|
Description string `json:"description"`
|
|
// PeriodKind beschreibt die Art des Zeitbezugs.
|
|
PeriodKind reports.PeriodKind `json:"period_kind"`
|
|
// DefaultPeriod beschreibt den Standardzeitraum.
|
|
DefaultPeriod string `json:"default_period,omitempty"`
|
|
// Formats sind die verfuegbaren Ausgabeformate.
|
|
Formats []reports.Format `json:"formats"`
|
|
}
|
|
|
|
// handleListReports bedient GET /reports.
|
|
func (handler *reportHandler) handleListReports(responseWriter http.ResponseWriter, request *http.Request) {
|
|
catalogEntries := make([]reportCatalogEntry, 0, 9)
|
|
|
|
for _, definition := range reports.Definitions() {
|
|
catalogEntries = append(catalogEntries, reportCatalogEntry{
|
|
Type: definition.Type,
|
|
Title: definition.Title,
|
|
Description: definition.Description,
|
|
PeriodKind: definition.PeriodKind,
|
|
DefaultPeriod: definition.DefaultPeriodLabel,
|
|
Formats: definition.Formats,
|
|
})
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, catalogEntries)
|
|
}
|
|
|
|
// generateReportRequest ist der Rumpf von POST /reports/generate.
|
|
type generateReportRequest struct {
|
|
// Type ist die gewuenschte Reportart.
|
|
Type string `json:"type"`
|
|
// From ist der Beginn des Zeitraums; ohne Angabe gilt der Standardzeitraum.
|
|
From *time.Time `json:"from"`
|
|
// To ist das Ende des Zeitraums; ohne Angabe gilt jetzt.
|
|
To *time.Time `json:"to"`
|
|
// Format ist das Ausgabeformat; ohne Angabe JSON.
|
|
Format string `json:"format"`
|
|
}
|
|
|
|
// handleGenerateReport bedient POST /reports/generate.
|
|
//
|
|
// Der Bericht wird erzeugt und unmittelbar ausgeliefert; er wird **nicht**
|
|
// gespeichert. Ein abgelegter Bericht veraltet mit jedem Tag, ohne dass sich an
|
|
// ihm etwas aendert — dieselbe Ueberlegung wie bei der Recovery Assurance
|
|
// (Phase 10) und der Sicherheitsbewertung (Phase 15). Wer ihn aufbewahren will,
|
|
// laedt ihn herunter; die Datei traegt ihren Erzeugungszeitpunkt bei sich.
|
|
func (handler *reportHandler) handleGenerateReport(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
var reportRequest generateReportRequest
|
|
|
|
if decodeError := decodeJSONBody(request, &reportRequest); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
|
|
return
|
|
}
|
|
|
|
reportType, typeError := reports.ParseType(reportRequest.Type)
|
|
if typeError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError(typeError.Error()))
|
|
|
|
return
|
|
}
|
|
|
|
outputFormat, formatError := reports.ParseFormat(reportRequest.Format)
|
|
if formatError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError(formatError.Error()))
|
|
|
|
return
|
|
}
|
|
|
|
definition, _ := reports.FindDefinition(reportType)
|
|
|
|
periodFrom, periodTo, periodError := reports.ResolvePeriod(definition,
|
|
reportRequest.From, reportRequest.To, time.Now().UTC())
|
|
if periodError != nil {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewBadRequestError(periodError.Error()))
|
|
|
|
return
|
|
}
|
|
|
|
currentUser, isAuthenticated := AuthenticatedUserFromContext(request.Context())
|
|
|
|
requestedBy := ""
|
|
if isAuthenticated {
|
|
requestedBy = currentUser.Username
|
|
}
|
|
|
|
generatedReport, generateError := handler.reportGenerator.Generate(request.Context(),
|
|
reportType, periodFrom, periodTo, requestedBy)
|
|
if generateError != nil {
|
|
if errors.Is(generateError, reports.ErrSecurityInspectorMissing) {
|
|
WriteError(responseWriter, request, requestLogger,
|
|
NewServiceUnavailableError("Für diesen Bericht ist keine Sicherheitsprüfung "+
|
|
"eingerichtet. Ein Bericht mit leeren Abschnitten sähe aus wie eine Anlage "+
|
|
"ohne Befunde."))
|
|
|
|
return
|
|
}
|
|
|
|
requestLogger.Error("der bericht konnte nicht erzeugt werden",
|
|
slog.String("art", string(reportType)),
|
|
slog.String("grund", generateError.Error()))
|
|
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(generateError))
|
|
|
|
return
|
|
}
|
|
|
|
// Erst vollstaendig in den Puffer, dann ausliefern. Schriebe der Erzeuger
|
|
// direkt in die Antwort, stuenden bei einem Fehler auf halber Strecke schon
|
|
// Kopfzeilen und ein halber Bericht beim Empfaenger — eine abgeschnittene
|
|
// CSV-Datei sieht aus wie eine vollstaendige mit weniger Zeilen.
|
|
outputBuffer := &bytes.Buffer{}
|
|
|
|
if writeError := reports.Write(outputBuffer, generatedReport, outputFormat); writeError != nil {
|
|
requestLogger.Error("der bericht konnte nicht ausgegeben werden",
|
|
slog.String("format", string(outputFormat)),
|
|
slog.String("grund", writeError.Error()))
|
|
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(writeError))
|
|
|
|
return
|
|
}
|
|
|
|
if isAuthenticated {
|
|
handler.recordGeneration(request, currentUser, generatedReport, outputFormat)
|
|
}
|
|
|
|
// JSON geht durch die uebliche Antworthuelle, damit der Frontend-Client
|
|
// nicht zwei Arten von Antworten unterscheiden muss. CSV und PDF sind
|
|
// Dateien und werden roh ausgeliefert.
|
|
if outputFormat == reports.FormatJSON {
|
|
WriteSuccess(responseWriter, request, http.StatusOK, generatedReport)
|
|
|
|
return
|
|
}
|
|
|
|
fileName := reports.SanitizeFileName(reports.FileName(generatedReport, outputFormat))
|
|
|
|
responseWriter.Header().Set("Content-Type", outputFormat.ContentType())
|
|
responseWriter.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
|
|
responseWriter.WriteHeader(http.StatusOK)
|
|
|
|
if _, writeError := responseWriter.Write(outputBuffer.Bytes()); writeError != nil {
|
|
requestLogger.Warn("der bericht konnte nicht vollstaendig gesendet werden",
|
|
slog.String("grund", writeError.Error()))
|
|
}
|
|
}
|
|
|
|
// recordGeneration protokolliert die Ausgabe eines Berichts.
|
|
//
|
|
// Scheitert das Protokollieren, wird der Bericht trotzdem ausgeliefert und der
|
|
// Fehler vermerkt: Ein Leserecht wegen eines Protokollfehlers zu verweigern
|
|
// waere die falsche Abwaegung — anders als bei einer destruktiven Handlung.
|
|
func (handler *reportHandler) recordGeneration(request *http.Request, currentUser auth.User,
|
|
generatedReport *reports.Report, outputFormat reports.Format) {
|
|
if handler.auditRecorder == nil {
|
|
return
|
|
}
|
|
|
|
actingUserIdentifier := currentUser.ID
|
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
|
|
|
auditEvent := audit.Event{
|
|
UserID: &actingUserIdentifier,
|
|
ActorUsername: currentUser.Username,
|
|
Action: audit.ActionReportGenerated,
|
|
EntityType: "report",
|
|
Result: audit.ResultSuccess,
|
|
IPAddress: clientIPAddress(request),
|
|
UserAgent: request.UserAgent(),
|
|
CorrelationID: correlationID,
|
|
Details: map[string]any{
|
|
"report_type": string(generatedReport.Type),
|
|
"format": string(outputFormat),
|
|
"period_from": generatedReport.PeriodFrom,
|
|
"period_to": generatedReport.PeriodTo,
|
|
},
|
|
}
|
|
|
|
if recordError := handler.auditRecorder.Record(request.Context(), auditEvent); recordError != nil {
|
|
logging.WithContext(request.Context(), handler.logger).Warn(
|
|
"die berichtsausgabe konnte nicht protokolliert werden",
|
|
slog.String("grund", recordError.Error()))
|
|
}
|
|
}
|