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

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
}