syncova-backup/packages/alerting/channels.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

323 lines
10 KiB
Go

package alerting
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/syncova/syncova/packages/platform/crypto"
)
// channelColumnList sind die Spalten eines Kanals in fester Reihenfolge.
//
// Das verschluesselte Geheimnis fehlt bewusst: Es verlaesst die Datenbank nur
// ueber LoadChannelSecret, und dieser Weg ist eine einzelne Funktion, die sich
// pruefen laesst.
const channelColumnList = `
id, name, channel_type, enabled, minimum_severity, configuration,
last_delivery_at, last_delivery_error, consecutive_failures, created_at`
// scanChannel liest eine Kanalzeile.
func scanChannel(scanner rowScanner) (*Channel, error) {
var (
loadedChannel Channel
channelTypeText string
severityText string
configurationJSON []byte
deliveryError *string
)
scanError := scanner.Scan(
&loadedChannel.ID, &loadedChannel.Name, &channelTypeText, &loadedChannel.Enabled,
&severityText, &configurationJSON, &loadedChannel.LastDeliveryAt, &deliveryError,
&loadedChannel.ConsecutiveFailures, &loadedChannel.CreatedAt)
if scanError != nil {
return nil, scanError
}
loadedChannel.ChannelType = ChannelType(channelTypeText)
loadedChannel.MinimumSeverity = Severity(severityText)
if deliveryError != nil {
loadedChannel.LastDeliveryError = *deliveryError
}
if len(configurationJSON) > 0 {
_ = json.Unmarshal(configurationJSON, &loadedChannel.Configuration)
}
return &loadedChannel, nil
}
// CreateChannelRequest beschreibt einen neuen Kanal.
type CreateChannelRequest struct {
// Name ist die sprechende Bezeichnung.
Name string
// ChannelType ist die Art der Zustellung.
ChannelType ChannelType
// MinimumSeverity ist der niedrigste zugestellte Schweregrad.
MinimumSeverity Severity
// Configuration traegt die Zustelldaten ohne Geheimnisse.
Configuration map[string]any
// Secret ist das Zugangsgeheimnis; leer, wenn keines noetig ist.
Secret string
// CreatedBy benennt den Anlegenden.
CreatedBy *uuid.UUID
}
// CreateChannel legt einen Benachrichtigungskanal an.
//
// Das Geheimnis wird mit demselben Secret Store verschluesselt, der die
// MFA-Geheimnisse und die Datenschluessel der Repositories schuetzt. Ein
// zweiter Schluesselsatz braechte keinen Sicherheitsgewinn, aber eine zweite
// Stelle, an der er verloren gehen kann.
func (store *Store) CreateChannel(createContext context.Context, request CreateChannelRequest, secretStore crypto.SecretStore) (*Channel, error) {
if request.Name == "" {
return nil, errors.New("der kanal braucht einen namen")
}
if request.ChannelType != ChannelEmail && request.ChannelType != ChannelWebhook {
return nil, fmt.Errorf("die kanalart %q ist unbekannt", request.ChannelType)
}
if request.MinimumSeverity == "" {
request.MinimumSeverity = SeverityHigh
}
configurationJSON, encodeError := json.Marshal(request.Configuration)
if encodeError != nil {
return nil, fmt.Errorf("die konfiguration konnte nicht abgelegt werden: %w", encodeError)
}
var (
encryptedSecret []byte
keyVersion *string
)
if request.Secret != "" {
if secretStore == nil {
return nil, errors.New("ohne verschluesselung laesst sich kein zugangsgeheimnis ablegen")
}
sealedSecret, sealedKeyVersion, sealError := secretStore.Encrypt([]byte(request.Secret))
if sealError != nil {
return nil, fmt.Errorf("das zugangsgeheimnis konnte nicht gesichert werden: %w", sealError)
}
encryptedSecret = sealedSecret
keyVersion = &sealedKeyVersion
}
const insertStatement = `
INSERT INTO notification_channels
(name, channel_type, minimum_severity, configuration,
credentials_ciphertext, credentials_key_version, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING ` + channelColumnList
createdChannel, scanError := scanChannel(store.connectionPool.QueryRow(createContext, insertStatement,
request.Name, string(request.ChannelType), string(request.MinimumSeverity),
configurationJSON, encryptedSecret, keyVersion, request.CreatedBy))
if scanError != nil {
return nil, fmt.Errorf("der kanal konnte nicht angelegt werden: %w", scanError)
}
return createdChannel, nil
}
// ListChannels liefert alle Benachrichtigungskanaele.
func (store *Store) ListChannels(listContext context.Context) ([]Channel, error) {
const selectStatement = `SELECT ` + channelColumnList + ` FROM notification_channels ORDER BY name`
channelRows, queryError := store.connectionPool.Query(listContext, selectStatement)
if queryError != nil {
return nil, fmt.Errorf("die kanaele konnten nicht gelesen werden: %w", queryError)
}
defer channelRows.Close()
loadedChannels := make([]Channel, 0, 4)
for channelRows.Next() {
loadedChannel, scanError := scanChannel(channelRows)
if scanError != nil {
return nil, fmt.Errorf("ein kanal konnte nicht gelesen werden: %w", scanError)
}
loadedChannels = append(loadedChannels, *loadedChannel)
}
return loadedChannels, channelRows.Err()
}
// GetChannel liest einen Kanal.
func (store *Store) GetChannel(readContext context.Context, channelIdentifier uuid.UUID) (*Channel, error) {
const selectStatement = `SELECT ` + channelColumnList + ` FROM notification_channels WHERE id = $1`
loadedChannel, scanError := scanChannel(store.connectionPool.QueryRow(readContext,
selectStatement, channelIdentifier))
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier)
}
if scanError != nil {
return nil, fmt.Errorf("der kanal konnte nicht gelesen werden: %w", scanError)
}
return loadedChannel, nil
}
// DeleteChannel entfernt einen Kanal.
func (store *Store) DeleteChannel(deleteContext context.Context, channelIdentifier uuid.UUID) error {
commandTag, execError := store.connectionPool.Exec(deleteContext,
`DELETE FROM notification_channels WHERE id = $1`, channelIdentifier)
if execError != nil {
return fmt.Errorf("der kanal konnte nicht geloescht werden: %w", execError)
}
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier)
}
return nil
}
// LoadChannelSecret entschluesselt das Zugangsgeheimnis eines Kanals.
//
// Der einzige Weg, auf dem ein Geheimnis die Datenbank verlaesst. Es geht
// niemals in eine API-Antwort, ein Protokoll oder eine Fehlermeldung
// (PROMPT.md §140).
func (store *Store) LoadChannelSecret(loadContext context.Context, channelIdentifier uuid.UUID, secretStore crypto.SecretStore) (string, error) {
const selectStatement = `
SELECT credentials_ciphertext, credentials_key_version
FROM notification_channels WHERE id = $1`
var (
encryptedSecret []byte
keyVersion *string
)
scanError := store.connectionPool.QueryRow(loadContext, selectStatement, channelIdentifier).
Scan(&encryptedSecret, &keyVersion)
if errors.Is(scanError, pgx.ErrNoRows) {
return "", fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier)
}
if scanError != nil {
return "", fmt.Errorf("das zugangsgeheimnis konnte nicht gelesen werden: %w", scanError)
}
if len(encryptedSecret) == 0 || keyVersion == nil {
return "", nil
}
if secretStore == nil {
return "", errors.New("ohne verschluesselung laesst sich das zugangsgeheimnis nicht lesen")
}
plaintextSecret, decryptError := secretStore.Decrypt(encryptedSecret, *keyVersion)
if decryptError != nil {
return "", fmt.Errorf("das zugangsgeheimnis liess sich nicht entschluesseln: %w", decryptError)
}
return string(plaintextSecret), nil
}
// RecordDelivery haelt eine Zustellung fest.
//
// Der Eindeutigkeitsindex verhindert eine zweite erfolgreiche Zustellung
// derselben Meldung ueber denselben Kanal. Ein Verstoss dagegen ist deshalb kein
// Fehler, sondern die Auskunft „wurde bereits zugestellt".
func (store *Store) RecordDelivery(recordContext context.Context, alertIdentifier uuid.UUID, channelIdentifier uuid.UUID, deliveryError error) error {
deliveryStatus := "sent"
var errorMessage *string
if deliveryError != nil {
deliveryStatus = "failed"
errorText := deliveryError.Error()
errorMessage = &errorText
}
const insertStatement = `
INSERT INTO notification_deliveries (alert_id, channel_id, status, error_message)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING`
if _, execError := store.connectionPool.Exec(recordContext, insertStatement,
alertIdentifier, channelIdentifier, deliveryStatus, errorMessage); execError != nil {
return fmt.Errorf("die zustellung konnte nicht vermerkt werden: %w", execError)
}
// Der Zustand des Kanals wird mitgefuehrt: Ein Kanal, der seit Wochen nichts
// zustellt, ist genauso schlimm wie eine fehlende Meldung.
if deliveryError != nil {
const failureStatement = `
UPDATE notification_channels
SET consecutive_failures = consecutive_failures + 1,
last_delivery_error = $2, updated_at = now()
WHERE id = $1`
_, updateError := store.connectionPool.Exec(recordContext, failureStatement,
channelIdentifier, deliveryError.Error())
return updateError
}
const successStatement = `
UPDATE notification_channels
SET consecutive_failures = 0, last_delivery_error = NULL,
last_delivery_at = now(), updated_at = now()
WHERE id = $1`
_, updateError := store.connectionPool.Exec(recordContext, successStatement, channelIdentifier)
return updateError
}
// WasDelivered meldet, ob eine Meldung ueber einen Kanal bereits zugestellt wurde.
func (store *Store) WasDelivered(checkContext context.Context, alertIdentifier uuid.UUID, channelIdentifier uuid.UUID) (bool, error) {
const checkStatement = `
SELECT EXISTS (
SELECT 1 FROM notification_deliveries
WHERE alert_id = $1 AND channel_id = $2 AND status = 'sent'
)`
var wasDelivered bool
if scanError := store.connectionPool.QueryRow(checkContext, checkStatement,
alertIdentifier, channelIdentifier).Scan(&wasDelivered); scanError != nil {
return false, fmt.Errorf("die zustellung konnte nicht geprueft werden: %w", scanError)
}
return wasDelivered, nil
}
// EnabledChannelsFor liefert die Kanaele, die einen Schweregrad zustellen.
func (store *Store) EnabledChannelsFor(listContext context.Context, severity Severity) ([]Channel, error) {
allChannels, listError := store.ListChannels(listContext)
if listError != nil {
return nil, listError
}
matchingChannels := make([]Channel, 0, len(allChannels))
for _, channel := range allChannels {
if !channel.Enabled {
continue
}
if !severity.AtLeast(channel.MinimumSeverity) {
continue
}
matchingChannels = append(matchingChannels, channel)
}
return matchingChannels, nil
}