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>
323 lines
10 KiB
Go
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
|
|
}
|