package alerting import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "net" "net/http" "net/smtp" "strings" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/platform/netguard" ) // ChannelType ist die Art einer Zustellung. type ChannelType string const ( // ChannelEmail stellt per SMTP zu. ChannelEmail ChannelType = "email" // ChannelWebhook stellt per HTTP-POST zu. ChannelWebhook ChannelType = "webhook" ) // Channel ist ein Benachrichtigungskanal. type Channel struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // Name ist die sprechende Bezeichnung. Name string `json:"name"` // ChannelType ist die Art der Zustellung. ChannelType ChannelType `json:"channel_type"` // Enabled meldet einen aktiven Kanal. Enabled bool `json:"enabled"` // MinimumSeverity ist der niedrigste zugestellte Schweregrad. MinimumSeverity Severity `json:"minimum_severity"` // Configuration traegt die Zustelldaten ohne Geheimnisse. Configuration map[string]any `json:"configuration"` // LastDeliveryAt ist der Zeitpunkt der letzten Zustellung in UTC. LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"` // LastDeliveryError ist der Grund des letzten Fehlschlags. // // Ein Kanal, der seit Wochen nichts zustellt, ist genauso schlimm wie eine // fehlende Meldung — und ohne dieses Feld faellt es niemandem auf. LastDeliveryError string `json:"last_delivery_error,omitempty"` // ConsecutiveFailures zaehlt die Fehlschlaege in Folge. ConsecutiveFailures int `json:"consecutive_failures"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // IsHealthy meldet einen zustellfaehigen Kanal. func (channel *Channel) IsHealthy() bool { return channel.Enabled && channel.ConsecutiveFailures == 0 } // deliveryTimeout begrenzt eine einzelne Zustellung. // // Ohne Grenze bliebe die Auswertungsschleife an einem nicht antwortenden // Empfaenger haengen — und die uebrigen Meldungen erreichten niemanden. const deliveryTimeout = 15 * time.Second // Deliverer stellt eine Meldung ueber einen Kanal zu. type Deliverer interface { // Deliver stellt die Meldung zu. Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error } // DeliveryDispatcher waehlt den passenden Zusteller. type DeliveryDispatcher struct { // emailDeliverer stellt per SMTP zu. emailDeliverer Deliverer // webhookDeliverer stellt per HTTP zu. webhookDeliverer Deliverer } // NewDeliveryDispatcher erzeugt die Zustellung. // // allowInternalTargets hebt den Schutz gegen interne Zieladressen auf. Er // gehoert in die Konfiguration eines Betreibers, der einen Meldedienst im // eigenen Netz betreibt — und nirgendwo sonst. func NewDeliveryDispatcher(allowInternalTargets bool) *DeliveryDispatcher { addressGuard := netguard.NewGuard(allowInternalTargets) return &DeliveryDispatcher{ emailDeliverer: &EmailDeliverer{addressGuard: addressGuard}, webhookDeliverer: &WebhookDeliverer{ httpClient: &http.Client{Timeout: deliveryTimeout}, addressGuard: addressGuard, }, } } // Deliver stellt eine Meldung ueber den angegebenen Kanal zu. func (dispatcher *DeliveryDispatcher) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error { switch channel.ChannelType { case ChannelEmail: return dispatcher.emailDeliverer.Deliver(deliveryContext, channel, secret, alert) case ChannelWebhook: return dispatcher.webhookDeliverer.Deliver(deliveryContext, channel, secret, alert) default: return fmt.Errorf("die kanalart %q ist unbekannt", channel.ChannelType) } } // EmailDeliverer stellt Meldungen per SMTP zu. type EmailDeliverer struct { // addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19). // // Auch ein SMTP-Server ist ein Ziel: Wer ihn auf 127.0.0.1:11211 richtet, // spricht mit einem Zwischenspeicher statt mit einem Mailserver — und // bekommt dessen Antwort ueber die Fehlermeldung zurueck. addressGuard *netguard.Guard } // Deliver verschickt eine Meldung als E-Mail. func (deliverer *EmailDeliverer) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error { smtpHost, hasHost := channel.Configuration["host"].(string) if !hasHost || smtpHost == "" { return errors.New("dem kanal fehlt die angabe host") } if deliverer.addressGuard != nil { if guardError := deliverer.addressGuard.CheckHost(deliveryContext, smtpHost); guardError != nil { return guardError } } smtpPort := 25 if portValue, hasPort := channel.Configuration["port"].(float64); hasPort { smtpPort = int(portValue) } senderAddress, hasSender := channel.Configuration["from"].(string) if !hasSender || senderAddress == "" { return errors.New("dem kanal fehlt die absenderadresse") } recipientAddresses := readStringList(channel.Configuration["to"]) if len(recipientAddresses) == 0 { return errors.New("dem kanal fehlt mindestens ein empfaenger") } messageBody := buildEmailMessage(senderAddress, recipientAddresses, alert) serverAddress := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort)) var smtpAuthentication smtp.Auth if userName, hasUser := channel.Configuration["username"].(string); hasUser && userName != "" { // PlainAuth verweigert sich auf unverschluesselten Verbindungen von // selbst — das ist gewollt und wird nicht umgangen: Ein Passwort im // Klartext ueber das Netz zu schicken waere schlimmer als keine // Benachrichtigung. smtpAuthentication = smtp.PlainAuth("", userName, secret, smtpHost) } // Ein eigener Kanal fuer das Ergebnis: net/smtp kennt keinen Kontext, und // ohne diese Umhuellung liefe ein haengender Server bis zum // Betriebssystem-Timeout. deliveryResult := make(chan error, 1) go func() { deliveryResult <- smtp.SendMail(serverAddress, smtpAuthentication, senderAddress, recipientAddresses, messageBody) }() select { case sendError := <-deliveryResult: if sendError != nil { return fmt.Errorf("die e-mail wurde nicht angenommen: %w", sendError) } return nil case <-time.After(deliveryTimeout): return fmt.Errorf("der smtp-server %s antwortete nicht innerhalb von %s", serverAddress, deliveryTimeout) case <-deliveryContext.Done(): return deliveryContext.Err() } } // buildEmailMessage baut die Nachricht einer Meldung. func buildEmailMessage(senderAddress string, recipientAddresses []string, alert Alert) []byte { var messageBuilder bytes.Buffer // Der Schweregrad steht im Betreff, nicht nur im Rumpf: Wer nachts auf sein // Telefon sieht, liest die Betreffzeile und sonst nichts. messageBuilder.WriteString(fmt.Sprintf("From: %s\r\n", senderAddress)) messageBuilder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(recipientAddresses, ", "))) messageBuilder.WriteString(fmt.Sprintf("Subject: [Syncova %s] %s\r\n", strings.ToUpper(string(alert.Severity)), alert.Title)) messageBuilder.WriteString("MIME-Version: 1.0\r\n") messageBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") messageBuilder.WriteString("\r\n") messageBuilder.WriteString(alert.Message) messageBuilder.WriteString("\r\n\r\n") messageBuilder.WriteString(fmt.Sprintf("Regel: %s\r\n", alert.RuleName)) messageBuilder.WriteString(fmt.Sprintf("Schweregrad: %s\r\n", alert.Severity)) if alert.EntityName != "" { messageBuilder.WriteString(fmt.Sprintf("Betroffen: %s (%s)\r\n", alert.EntityName, alert.EntityType)) } messageBuilder.WriteString(fmt.Sprintf("Erstmals: %s\r\n", alert.FirstSeenAt.Format(time.RFC3339))) // Die Meldungskennung gehoert in die Mail: Mit ihr laesst sich der Vorgang // in der Oberflaeche wiederfinden, ohne zu suchen. messageBuilder.WriteString(fmt.Sprintf("Meldung: %s\r\n", alert.ID)) return messageBuilder.Bytes() } // readStringList liest eine Liste von Zeichenketten aus der Konfiguration. func readStringList(configurationValue any) []string { rawList, isList := configurationValue.([]any) if !isList { if singleValue, isString := configurationValue.(string); isString && singleValue != "" { return []string{singleValue} } return nil } stringList := make([]string, 0, len(rawList)) for _, rawValue := range rawList { if stringValue, isString := rawValue.(string); isString && stringValue != "" { stringList = append(stringList, stringValue) } } return stringList } // WebhookDeliverer stellt Meldungen per HTTP-POST zu. type WebhookDeliverer struct { // httpClient fuehrt die Anfrage aus. httpClient *http.Client // addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19). // // Er darf nil sein — dann findet keine Pruefung statt. Das ist // ausschliesslich fuer Tests gedacht, die gegen einen lokalen Testserver // zustellen; im Betrieb setzt NewDeliveryDispatcher ihn immer. addressGuard *netguard.Guard } // webhookPayload ist der Rumpf einer Webhook-Zustellung. // // Bewusst eine eigene Struktur und nicht die Meldung selbst: Der Vertrag nach // aussen darf sich nicht mitaendern, wenn ein internes Feld umbenannt wird. type webhookPayload struct { // AlertID ist die Meldungskennung. AlertID uuid.UUID `json:"alert_id"` // Rule benennt die ausloesende Regel. Rule string `json:"rule"` // Severity ist der Schweregrad. Severity Severity `json:"severity"` // Status ist der Bearbeitungszustand. Status Status `json:"status"` // Title ist die Ueberschrift. Title string `json:"title"` // Message erklaert den Befund. Message string `json:"message"` // EntityType benennt die Art des betroffenen Gegenstands. EntityType string `json:"entity_type,omitempty"` // EntityName ist sein sprechender Name. EntityName string `json:"entity_name,omitempty"` // OccurrenceCount zaehlt das Auftreten. OccurrenceCount int `json:"occurrence_count"` // FirstSeenAt ist das erste Auftreten in UTC. FirstSeenAt time.Time `json:"first_seen_at"` // Details traegt die Messwerte. Details map[string]any `json:"details,omitempty"` } // Deliver schickt eine Meldung an einen Webhook. func (deliverer *WebhookDeliverer) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error { targetURL, hasURL := channel.Configuration["url"].(string) if !hasURL || targetURL == "" { return errors.New("dem kanal fehlt die zieladresse") } // Nur HTTPS, sofern nicht ausdruecklich anders gewuenscht: Eine Meldung // ueber unverschluesseltes HTTP verraet einem Mitleser, welche Anlage // gerade nicht gesichert wird. allowsPlainHTTP, _ := channel.Configuration["allow_insecure"].(bool) if !strings.HasPrefix(targetURL, "https://") && !allowsPlainHTTP { return errors.New("die zieladresse ist nicht https; setzen Sie allow_insecure, " + "wenn das beabsichtigt ist") } // Die Adresspruefung sitzt hier ein **zweites** Mal — beim Anlegen des // Kanals fand sie bereits statt. Der Grund ist kein Uebereifer: Ein Name // kann zwischen beiden Zeitpunkten auf eine andere Adresse zeigen. Ein // DNS-Eintrag, der beim Pruefen oeffentlich und beim Zustellen intern // auffloest, ist der uebliche Weg um eine einmalige Pruefung herum. if deliverer.addressGuard != nil { if guardError := deliverer.addressGuard.CheckURL(deliveryContext, targetURL); guardError != nil { return guardError } } payloadBytes, encodeError := json.Marshal(webhookPayload{ AlertID: alert.ID, Rule: alert.RuleName, Severity: alert.Severity, Status: alert.Status, Title: alert.Title, Message: alert.Message, EntityType: alert.EntityType, EntityName: alert.EntityName, OccurrenceCount: alert.OccurrenceCount, FirstSeenAt: alert.FirstSeenAt, Details: alert.Details, }) if encodeError != nil { return fmt.Errorf("die nachricht konnte nicht gebildet werden: %w", encodeError) } requestContext, cancelRequest := context.WithTimeout(deliveryContext, deliveryTimeout) defer cancelRequest() httpRequest, requestError := http.NewRequestWithContext(requestContext, http.MethodPost, targetURL, bytes.NewReader(payloadBytes)) if requestError != nil { return fmt.Errorf("die anfrage konnte nicht gebildet werden: %w", requestError) } httpRequest.Header.Set("Content-Type", "application/json") httpRequest.Header.Set("User-Agent", "Syncova") if secret != "" { // Der Empfaenger soll pruefen koennen, dass die Meldung von dieser // Anlage stammt. Ein Bearer-Token ist der einfachste Weg, der bei jedem // Empfaenger funktioniert. httpRequest.Header.Set("Authorization", "Bearer "+secret) } httpResponse, sendError := deliverer.httpClient.Do(httpRequest) if sendError != nil { return fmt.Errorf("der empfaenger war nicht erreichbar: %w", sendError) } defer func() { _ = httpResponse.Body.Close() }() if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { return fmt.Errorf("der empfaenger antwortete mit %d", httpResponse.StatusCode) } return nil } // insecureTLSConfiguration ist absichtlich nicht vorhanden. // // Wer eine Meldung an einen Empfaenger mit selbstsigniertem Zertifikat schicken // will, hinterlegt dessen Wurzelzertifikat im System. Ein Schalter // „Zertifikat egal" waere die bequeme Loesung und der Weg, ueber den ein // Angreifer erfaehrt, welche Anlage gerade unbewacht ist. var _ = tls.Config{}