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>
362 lines
14 KiB
Go
362 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/alerting"
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/platform/netguard"
|
|
)
|
|
|
|
// alertHandler bedient Meldungen und Benachrichtigungen (Phase 14).
|
|
type alertHandler struct {
|
|
// alertStore ist die Datenzugriffsschicht der Meldungen.
|
|
alertStore *alerting.Store
|
|
// secretStore verschlüsselt die Zugangsgeheimnisse der Kanäle.
|
|
secretStore crypto.SecretStore
|
|
// auditRecorder protokolliert Änderungen an Kanälen.
|
|
auditRecorder audit.Recorder
|
|
// addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19).
|
|
addressGuard *netguard.Guard
|
|
// logger protokolliert technische Fehler.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// acknowledgeRequest ist der Rumpf von POST /alerts/{id}/acknowledge.
|
|
type acknowledgeRequest struct {
|
|
// Note ist eine Bemerkung des Bestätigenden.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// resolveRequest ist der Rumpf von POST /alerts/{id}/resolve.
|
|
type resolveRequest struct {
|
|
// Note begründet die Auflösung.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// channelRequest ist der Rumpf von POST /notification-channels.
|
|
type channelRequest struct {
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string `json:"name"`
|
|
// ChannelType ist die Art der Zustellung.
|
|
ChannelType string `json:"channel_type"`
|
|
// MinimumSeverity ist der niedrigste zugestellte Schweregrad.
|
|
MinimumSeverity string `json:"minimum_severity,omitempty"`
|
|
// Configuration trägt die Zustelldaten ohne Geheimnisse.
|
|
Configuration map[string]any `json:"configuration"`
|
|
// Secret ist das Zugangsgeheimnis.
|
|
//
|
|
// Es geht nur hinein, nie heraus: Keine Antwort dieses Handlers enthält es
|
|
// jemals wieder (PROMPT.md §140).
|
|
Secret string `json:"secret,omitempty"`
|
|
}
|
|
|
|
// handleListAlerts bedient GET /alerts.
|
|
func (handler *alertHandler) handleListAlerts(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
listFilter := alerting.AlertFilter{
|
|
Status: request.URL.Query().Get("status"),
|
|
Severity: request.URL.Query().Get("severity"),
|
|
OnlyActive: request.URL.Query().Get("only_active") == "true",
|
|
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
|
|
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
|
|
}
|
|
|
|
loadedAlerts, totalCount, listError := handler.alertStore.ListAlerts(request.Context(), listFilter)
|
|
if listError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
|
return
|
|
}
|
|
|
|
WritePaginatedSuccess(responseWriter, request, loadedAlerts, PaginationMeta{
|
|
Page: listFilter.Page,
|
|
PageSize: listFilter.PageSize,
|
|
Total: int64(totalCount),
|
|
})
|
|
}
|
|
|
|
// handleAlertSummary bedient GET /alerts/summary.
|
|
//
|
|
// Getrennt von der Liste, weil die Übersicht sie bei jedem Aufruf braucht: Eine
|
|
// Seite von fünfzig Meldungen zu laden, um drei Zahlen zu bilden, wäre Aufwand
|
|
// ohne Nutzen.
|
|
func (handler *alertHandler) handleAlertSummary(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
summary, summaryError := handler.alertStore.Summary(request.Context())
|
|
if summaryError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(summaryError))
|
|
return
|
|
}
|
|
|
|
// Das Regelwerk kommt mit: Wer die Meldungslage beurteilt, muss wissen,
|
|
// worauf überhaupt geachtet wird — und worauf nicht.
|
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
|
"summary": summary,
|
|
"rules": alerting.Rules(),
|
|
"available_rule_count": alerting.AvailableRuleCount(),
|
|
})
|
|
}
|
|
|
|
// handleGetAlert bedient GET /alerts/{id}.
|
|
func (handler *alertHandler) handleGetAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
loadedAlert, readError := handler.alertStore.GetAlert(request.Context(), alertIdentifier)
|
|
if readError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(readError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedAlert)
|
|
}
|
|
|
|
// handleAcknowledgeAlert bedient POST /alerts/{id}/acknowledge.
|
|
func (handler *alertHandler) handleAcknowledgeAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
var acknowledgePayload acknowledgeRequest
|
|
if decodeError := decodeJSONBody(request, &acknowledgePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
acknowledgedAlert, acknowledgeError := handler.alertStore.AcknowledgeAlert(request.Context(),
|
|
alertIdentifier, actingUser.ID, acknowledgePayload.Note)
|
|
if acknowledgeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(acknowledgeError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
|
"alert": acknowledgedAlert,
|
|
"message": "Die Meldung ist zur Kenntnis genommen. Sie bleibt offen, bis ihre Ursache " +
|
|
"verschwindet — Bestätigen heisst nicht Erledigen.",
|
|
})
|
|
}
|
|
|
|
// handleResolveAlert bedient POST /alerts/{id}/resolve.
|
|
func (handler *alertHandler) handleResolveAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
var resolvePayload resolveRequest
|
|
if decodeError := decodeJSONBody(request, &resolvePayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
resolvedAlert, resolveError := handler.alertStore.ResolveAlert(request.Context(),
|
|
alertIdentifier, actingUser.ID, resolvePayload.Note)
|
|
if resolveError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(resolveError))
|
|
return
|
|
}
|
|
|
|
// Der Hinweis ist wichtig: Die Auswertung prüft im nächsten Durchgang
|
|
// erneut. Besteht die Ursache fort, entsteht eine neue Meldung — von Hand
|
|
// schliessen macht ein Problem nicht weg.
|
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
|
"alert": resolvedAlert,
|
|
"message": "Die Meldung wurde geschlossen. Besteht die Ursache weiterhin, erzeugt die " +
|
|
"nächste Auswertung eine neue Meldung.",
|
|
})
|
|
}
|
|
|
|
// handleListChannels bedient GET /notification-channels.
|
|
func (handler *alertHandler) handleListChannels(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
|
|
loadedChannels, listError := handler.alertStore.ListChannels(request.Context())
|
|
if listError != nil {
|
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
|
return
|
|
}
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedChannels)
|
|
}
|
|
|
|
// handleCreateChannel bedient POST /notification-channels.
|
|
func (handler *alertHandler) handleCreateChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
var channelPayload channelRequest
|
|
if decodeError := decodeJSONBody(request, &channelPayload); decodeError != nil {
|
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
|
return
|
|
}
|
|
|
|
// Die Zieladresse wird geprüft, bevor der Kanal entsteht.
|
|
//
|
|
// Die Zustellung prüft ein zweites Mal — dort erst kurz vor dem
|
|
// Verbindungsaufbau, weil ein Name zwischenzeitlich auf eine andere Adresse
|
|
// zeigen kann. Diese Prüfung hier hat einen anderen Zweck: Der Betreiber
|
|
// soll die Meldung sofort sehen und nicht erst, wenn eine Meldung
|
|
// ausbleibt.
|
|
if guardError := handler.checkChannelTarget(request.Context(), channelPayload); guardError != nil {
|
|
WriteError(responseWriter, request, requestLogger, guardError)
|
|
|
|
return
|
|
}
|
|
|
|
createdChannel, createError := handler.alertStore.CreateChannel(request.Context(),
|
|
alerting.CreateChannelRequest{
|
|
Name: channelPayload.Name,
|
|
ChannelType: alerting.ChannelType(channelPayload.ChannelType),
|
|
MinimumSeverity: alerting.Severity(channelPayload.MinimumSeverity),
|
|
Configuration: channelPayload.Configuration,
|
|
Secret: channelPayload.Secret,
|
|
CreatedBy: &actingUser.ID,
|
|
}, handler.secretStore)
|
|
if createError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(createError))
|
|
return
|
|
}
|
|
|
|
// Der Kanal wird auditiert: Wer Benachrichtigungen umleitet, kann damit
|
|
// erreichen, dass niemand mehr von einem Ausfall erfährt.
|
|
handler.recordAudit(request, actingUser, audit.ActionNotificationChannelCreated,
|
|
&createdChannel.ID, map[string]any{
|
|
"name": createdChannel.Name,
|
|
"channel_type": string(createdChannel.ChannelType),
|
|
"minimum_severity": string(createdChannel.MinimumSeverity),
|
|
})
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdChannel)
|
|
}
|
|
|
|
// handleDeleteChannel bedient DELETE /notification-channels/{id}.
|
|
func (handler *alertHandler) handleDeleteChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
|
|
|
channelIdentifier, parseError := parsePathIdentifier(request, "Die Kanalkennung ist keine gültige UUID.")
|
|
if parseError != nil {
|
|
WriteError(responseWriter, request, requestLogger, parseError)
|
|
return
|
|
}
|
|
|
|
existingChannel, readError := handler.alertStore.GetChannel(request.Context(), channelIdentifier)
|
|
if readError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(readError))
|
|
return
|
|
}
|
|
|
|
if deleteError := handler.alertStore.DeleteChannel(request.Context(), channelIdentifier); deleteError != nil {
|
|
WriteError(responseWriter, request, requestLogger, translateAlertError(deleteError))
|
|
return
|
|
}
|
|
|
|
handler.recordAudit(request, actingUser, audit.ActionNotificationChannelDeleted,
|
|
&channelIdentifier, map[string]any{"name": existingChannel.Name})
|
|
|
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
|
"message": "Der Benachrichtigungskanal wurde gelöscht. Meldungen entstehen weiterhin, " +
|
|
"werden aber nicht mehr über diesen Weg zugestellt.",
|
|
})
|
|
}
|
|
|
|
// recordAudit schreibt ein Auditereignis.
|
|
func (handler *alertHandler) 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: "notification_channel",
|
|
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(
|
|
"das auditereignis konnte nicht geschrieben werden",
|
|
slog.String("aktion", string(auditAction)),
|
|
slog.String("grund", recordError.Error()))
|
|
}
|
|
}
|
|
|
|
// translateAlertError bildet Fehler der Fachschicht auf API-Fehler ab.
|
|
func translateAlertError(occurredError error) *APIError {
|
|
switch {
|
|
case errors.Is(occurredError, alerting.ErrAlertNotFound):
|
|
return NewNotFoundError("Die Meldung wurde nicht gefunden.")
|
|
|
|
case errors.Is(occurredError, alerting.ErrChannelNotFound):
|
|
return NewNotFoundError("Der Benachrichtigungskanal wurde nicht gefunden.")
|
|
|
|
case errors.Is(occurredError, alerting.ErrAlertNotOpen):
|
|
// 409 und nicht 404: Die Meldung gibt es, sie ist nur schon erledigt.
|
|
conflictError := NewValidationError("Diese Meldung ist bereits erledigt.")
|
|
conflictError.Code = ErrorCodeConflict
|
|
conflictError.StatusCode = http.StatusConflict
|
|
|
|
return conflictError
|
|
|
|
default:
|
|
return NewInternalError(occurredError)
|
|
}
|
|
}
|
|
|
|
// checkChannelTarget prüft die Zieladresse eines Benachrichtigungswegs.
|
|
//
|
|
// Geprüft werden Webhook-Adresse und SMTP-Server. Fehlt die Angabe, greift
|
|
// diese Prüfung nicht — die fachliche Vollständigkeit prüft der Store.
|
|
func (handler *alertHandler) checkChannelTarget(checkContext context.Context,
|
|
channelPayload channelRequest) *APIError {
|
|
if handler.addressGuard == nil || channelPayload.Configuration == nil {
|
|
return nil
|
|
}
|
|
|
|
if webhookURL, hasURL := channelPayload.Configuration["url"].(string); hasURL && webhookURL != "" {
|
|
if guardError := handler.addressGuard.CheckURL(checkContext, webhookURL); guardError != nil {
|
|
return NewValidationError(guardError.Error())
|
|
}
|
|
}
|
|
|
|
if smtpHost, hasHost := channelPayload.Configuration["host"].(string); hasHost && smtpHost != "" {
|
|
if guardError := handler.addressGuard.CheckHost(checkContext, smtpHost); guardError != nil {
|
|
return NewValidationError(guardError.Error())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|