Add email as a recognized, feature-flagged notification service type. The flag defaults to false and acts as a dispatch gate alongside the existing discord, gotify, and webhook notification service flags. - Add FlagEmailServiceEnabled constant to the notifications feature flag registry with the canonical key convention - Register the flag in the handler defaults so it appears in the feature flags API response with a false default - Recognise 'email' as a supported notification provider type so that providers of this type pass the type validation gate - Gate email dispatch on the new flag in isDispatchEnabled() following the same pattern as gotify and webhook service flags - Expand the E2E test fixtures FeatureFlags interface to include the new flag key so typed fixture objects remain accurate No email message dispatch is wired in this commit; the flag registration alone makes the email provider type valid and toggleable.
728 lines
24 KiB
Go
728 lines
24 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
neturl "net/url"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/Wikid82/charon/backend/internal/network"
|
|
"github.com/Wikid82/charon/backend/internal/notifications"
|
|
"github.com/Wikid82/charon/backend/internal/security"
|
|
"github.com/Wikid82/charon/backend/internal/trace"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/util"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type NotificationService struct {
|
|
DB *gorm.DB
|
|
httpWrapper *notifications.HTTPWrapper
|
|
}
|
|
|
|
func NewNotificationService(db *gorm.DB) *NotificationService {
|
|
return &NotificationService{
|
|
DB: db,
|
|
httpWrapper: notifications.NewNotifyHTTPWrapper(),
|
|
}
|
|
}
|
|
|
|
var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`)
|
|
|
|
var allowedDiscordWebhookHosts = map[string]struct{}{
|
|
"discord.com": {},
|
|
"canary.discord.com": {},
|
|
}
|
|
|
|
func normalizeURL(serviceType, rawURL string) string {
|
|
if serviceType == "discord" {
|
|
matches := discordWebhookRegex.FindStringSubmatch(rawURL)
|
|
if len(matches) == 3 {
|
|
id := matches[1]
|
|
token := matches[2]
|
|
return fmt.Sprintf("discord://%s@%s", token, id)
|
|
}
|
|
}
|
|
return rawURL
|
|
}
|
|
|
|
func validateDiscordWebhookURL(rawURL string) error {
|
|
parsedURL, err := neturl.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid Discord webhook URL: failed to parse URL; use the HTTPS webhook URL provided by Discord")
|
|
}
|
|
|
|
if strings.EqualFold(parsedURL.Scheme, "discord") {
|
|
return nil
|
|
}
|
|
|
|
if !strings.EqualFold(parsedURL.Scheme, "https") {
|
|
return fmt.Errorf("invalid Discord webhook URL: URL must use HTTPS and the hostname URL provided by Discord")
|
|
}
|
|
|
|
hostname := strings.ToLower(parsedURL.Hostname())
|
|
if hostname == "" {
|
|
return fmt.Errorf("invalid Discord webhook URL: missing hostname; use the HTTPS webhook URL provided by Discord")
|
|
}
|
|
|
|
if net.ParseIP(hostname) != nil {
|
|
return fmt.Errorf("invalid Discord webhook URL: IP address hosts are not allowed; use the hostname URL provided by Discord (discord.com or canary.discord.com)")
|
|
}
|
|
|
|
if _, ok := allowedDiscordWebhookHosts[hostname]; !ok {
|
|
return fmt.Errorf("invalid Discord webhook URL: host must be discord.com or canary.discord.com; use the hostname URL provided by Discord")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateDiscordProviderURL(providerType, rawURL string) error {
|
|
if !strings.EqualFold(providerType, "discord") {
|
|
return nil
|
|
}
|
|
|
|
return validateDiscordWebhookURL(rawURL)
|
|
}
|
|
|
|
// supportsJSONTemplates returns true if the provider type can use JSON templates
|
|
func supportsJSONTemplates(providerType string) bool {
|
|
switch strings.ToLower(providerType) {
|
|
case "webhook", "discord", "gotify", "slack", "generic":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isSupportedNotificationProviderType(providerType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(providerType)) {
|
|
case "discord", "email", "gotify", "webhook":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *NotificationService) isDispatchEnabled(providerType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(providerType)) {
|
|
case "discord":
|
|
return true
|
|
case "email":
|
|
return s.getFeatureFlagValue(notifications.FlagEmailServiceEnabled, false)
|
|
case "gotify":
|
|
return s.getFeatureFlagValue(notifications.FlagGotifyServiceEnabled, true)
|
|
case "webhook":
|
|
return s.getFeatureFlagValue(notifications.FlagWebhookServiceEnabled, true)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *NotificationService) getFeatureFlagValue(key string, fallback bool) bool {
|
|
var setting models.Setting
|
|
err := s.DB.Where("key = ?", key).First(&setting).Error
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
|
|
v := strings.ToLower(strings.TrimSpace(setting.Value))
|
|
return v == "1" || v == "true" || v == "yes"
|
|
}
|
|
|
|
// Internal Notifications (DB)
|
|
|
|
func (s *NotificationService) Create(nType models.NotificationType, title, message string) (*models.Notification, error) {
|
|
notification := &models.Notification{
|
|
Type: nType,
|
|
Title: title,
|
|
Message: message,
|
|
Read: false,
|
|
}
|
|
result := s.DB.Create(notification)
|
|
return notification, result.Error
|
|
}
|
|
|
|
func (s *NotificationService) List(unreadOnly bool) ([]models.Notification, error) {
|
|
var notifications []models.Notification
|
|
query := s.DB.Order("created_at desc")
|
|
if unreadOnly {
|
|
query = query.Where("read = ?", false)
|
|
}
|
|
result := query.Find(¬ifications)
|
|
return notifications, result.Error
|
|
}
|
|
|
|
func (s *NotificationService) MarkAsRead(id string) error {
|
|
return s.DB.Model(&models.Notification{}).Where("id = ?", id).Update("read", true).Error
|
|
}
|
|
|
|
func (s *NotificationService) MarkAllAsRead() error {
|
|
return s.DB.Model(&models.Notification{}).Where("read = ?", false).Update("read", true).Error
|
|
}
|
|
|
|
// External Notifications (Custom Webhooks)
|
|
|
|
func (s *NotificationService) SendExternal(ctx context.Context, eventType, title, message string, data map[string]any) {
|
|
var providers []models.NotificationProvider
|
|
if err := s.DB.Where("enabled = ?", true).Find(&providers).Error; err != nil {
|
|
logger.Log().WithError(err).Error("Failed to fetch notification providers")
|
|
return
|
|
}
|
|
|
|
// Prepare data for templates
|
|
if data == nil {
|
|
data = make(map[string]any)
|
|
}
|
|
data["Title"] = title
|
|
data["Message"] = message
|
|
data["Time"] = time.Now().Format(time.RFC3339)
|
|
data["EventType"] = eventType
|
|
|
|
for _, provider := range providers {
|
|
// Filter based on preferences
|
|
shouldSend := false
|
|
switch eventType {
|
|
case "proxy_host":
|
|
shouldSend = provider.NotifyProxyHosts
|
|
case "remote_server":
|
|
shouldSend = provider.NotifyRemoteServers
|
|
case "domain":
|
|
shouldSend = provider.NotifyDomains
|
|
case "cert":
|
|
shouldSend = provider.NotifyCerts
|
|
case "uptime":
|
|
shouldSend = provider.NotifyUptime
|
|
case "security_waf":
|
|
shouldSend = provider.NotifySecurityWAFBlocks
|
|
case "security_acl":
|
|
shouldSend = provider.NotifySecurityACLDenies
|
|
case "security_rate_limit":
|
|
shouldSend = provider.NotifySecurityRateLimitHits
|
|
case "security_crowdsec":
|
|
shouldSend = provider.NotifySecurityCrowdSecDecisions
|
|
case "test":
|
|
shouldSend = true
|
|
default:
|
|
// Unknown event types default to false for security
|
|
shouldSend = false
|
|
}
|
|
|
|
if !shouldSend {
|
|
continue
|
|
}
|
|
if !s.isDispatchEnabled(provider.Type) {
|
|
logger.Log().WithField("provider", util.SanitizeForLog(provider.Name)).
|
|
WithField("type", provider.Type).
|
|
Warn("Skipping dispatch because provider type is disabled for notify dispatch")
|
|
continue
|
|
}
|
|
go func(p models.NotificationProvider) {
|
|
if !supportsJSONTemplates(p.Type) {
|
|
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime")
|
|
return
|
|
}
|
|
|
|
if err := s.sendJSONPayload(ctx, p, data); err != nil {
|
|
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send JSON notification")
|
|
}
|
|
}(provider)
|
|
}
|
|
}
|
|
|
|
// webhookDoRequestFunc is a test hook for outbound JSON webhook requests.
|
|
// In production it defaults to (*http.Client).Do.
|
|
var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
return client.Do(req)
|
|
}
|
|
|
|
// validateDiscordProviderURLFunc is a test hook for Discord webhook URL validation.
|
|
// In tests, you can override this to bypass strict hostname checks for localhost testing.
|
|
var validateDiscordProviderURLFunc = validateDiscordProviderURL
|
|
|
|
func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.NotificationProvider, data map[string]any) error {
|
|
// Built-in templates
|
|
const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}`
|
|
const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}`
|
|
|
|
// Select template based on provider.Template; if 'custom' use Config; else builtin.
|
|
tmplStr := p.Config
|
|
switch strings.ToLower(strings.TrimSpace(p.Template)) {
|
|
case "detailed":
|
|
tmplStr = detailedTemplate
|
|
case "minimal":
|
|
tmplStr = minimalTemplate
|
|
case "custom":
|
|
if tmplStr == "" {
|
|
tmplStr = minimalTemplate
|
|
}
|
|
default:
|
|
if tmplStr == "" {
|
|
tmplStr = minimalTemplate
|
|
}
|
|
}
|
|
|
|
// Template size limit validation (10KB max)
|
|
const maxTemplateSize = 10 * 1024
|
|
if len(tmplStr) > maxTemplateSize {
|
|
return fmt.Errorf("template size exceeds maximum limit of %d bytes", maxTemplateSize)
|
|
}
|
|
|
|
providerType := strings.ToLower(strings.TrimSpace(p.Type))
|
|
if providerType == "discord" {
|
|
if err := validateDiscordProviderURLFunc(p.Type, p.URL); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !isValidRedirectURL(p.URL) {
|
|
return fmt.Errorf("invalid webhook url")
|
|
}
|
|
}
|
|
|
|
// Parse template and add helper funcs
|
|
tmpl, err := template.New("webhook").Funcs(template.FuncMap{
|
|
"toJSON": func(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
},
|
|
}).Parse(tmplStr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse webhook template: %w", err)
|
|
}
|
|
|
|
// Template execution with timeout (5 seconds)
|
|
var body bytes.Buffer
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
execDone <- tmpl.Execute(&body, data)
|
|
}()
|
|
|
|
select {
|
|
case execErr := <-execDone:
|
|
if execErr != nil {
|
|
return fmt.Errorf("failed to execute webhook template: %w", execErr)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
return fmt.Errorf("template execution timeout after 5 seconds")
|
|
}
|
|
|
|
// Service-specific JSON validation
|
|
var jsonPayload map[string]any
|
|
if unmarshalErr := json.Unmarshal(body.Bytes(), &jsonPayload); unmarshalErr != nil {
|
|
return fmt.Errorf("invalid JSON payload: %w", unmarshalErr)
|
|
}
|
|
|
|
// Validate service-specific requirements
|
|
switch strings.ToLower(p.Type) {
|
|
case "discord":
|
|
// Discord requires either 'content' or 'embeds'
|
|
if _, hasContent := jsonPayload["content"]; !hasContent {
|
|
if _, hasEmbeds := jsonPayload["embeds"]; !hasEmbeds {
|
|
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
|
|
jsonPayload["content"] = messageValue
|
|
normalizedBody, marshalErr := json.Marshal(jsonPayload)
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("failed to normalize discord payload: %w", marshalErr)
|
|
}
|
|
body.Reset()
|
|
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
|
|
return fmt.Errorf("failed to write normalized discord payload: %w", writeErr)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("discord payload requires 'content' or 'embeds' field")
|
|
}
|
|
}
|
|
}
|
|
case "slack":
|
|
// Slack requires either 'text' or 'blocks'
|
|
if _, hasText := jsonPayload["text"]; !hasText {
|
|
if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks {
|
|
return fmt.Errorf("slack payload requires 'text' or 'blocks' field")
|
|
}
|
|
}
|
|
case "gotify":
|
|
// Gotify requires 'message' field
|
|
if _, hasMessage := jsonPayload["message"]; !hasMessage {
|
|
return fmt.Errorf("gotify payload requires 'message' field")
|
|
}
|
|
}
|
|
|
|
if providerType == "gotify" || providerType == "webhook" {
|
|
headers := map[string]string{
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Charon-Notify/1.0",
|
|
}
|
|
if rid := ctx.Value(trace.RequestIDKey); rid != nil {
|
|
if ridStr, ok := rid.(string); ok {
|
|
headers["X-Request-ID"] = ridStr
|
|
}
|
|
}
|
|
if providerType == "gotify" {
|
|
if strings.TrimSpace(p.Token) != "" {
|
|
headers["X-Gotify-Key"] = strings.TrimSpace(p.Token)
|
|
}
|
|
}
|
|
|
|
if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{
|
|
URL: p.URL,
|
|
Headers: headers,
|
|
Body: body.Bytes(),
|
|
}); sendErr != nil {
|
|
return fmt.Errorf("failed to send webhook: %w", sendErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
validatedURLStr, err := security.ValidateExternalURL(p.URL,
|
|
security.WithAllowHTTP(),
|
|
security.WithAllowLocalhost(),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid webhook url: %w", err)
|
|
}
|
|
|
|
client := network.NewSafeHTTPClient(
|
|
network.WithTimeout(10*time.Second),
|
|
network.WithAllowLocalhost(),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", validatedURLStr, &body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create webhook request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if rid := ctx.Value(trace.RequestIDKey); rid != nil {
|
|
if ridStr, ok := rid.(string); ok {
|
|
req.Header.Set("X-Request-ID", ridStr)
|
|
}
|
|
}
|
|
|
|
resp, err := webhookDoRequestFunc(client, req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send webhook: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := resp.Body.Close(); err != nil {
|
|
logger.Log().WithError(err).Warn("failed to close webhook response body")
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("webhook returned status: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isPrivateIP returns true for RFC1918, loopback and link-local addresses.
|
|
// This wraps network.IsPrivateIP for backward compatibility and local use.
|
|
func isPrivateIP(ip net.IP) bool {
|
|
return network.IsPrivateIP(ip)
|
|
}
|
|
|
|
func isValidRedirectURL(rawURL string) bool {
|
|
u, err := neturl.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return false
|
|
}
|
|
if u.Hostname() == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *NotificationService) TestProvider(provider models.NotificationProvider) error {
|
|
providerType := strings.ToLower(strings.TrimSpace(provider.Type))
|
|
if !isSupportedNotificationProviderType(providerType) {
|
|
return fmt.Errorf("unsupported provider type: %s", providerType)
|
|
}
|
|
|
|
if err := validateDiscordProviderURLFunc(providerType, provider.URL); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !supportsJSONTemplates(providerType) {
|
|
return fmt.Errorf("provider type %q does not support JSON templates", providerType)
|
|
}
|
|
|
|
data := map[string]any{
|
|
"Title": "Test Notification",
|
|
"Message": "This is a test notification from Charon",
|
|
"Status": "TEST",
|
|
"Name": "Test Monitor",
|
|
"Latency": 123,
|
|
"Time": time.Now().Format(time.RFC3339),
|
|
}
|
|
return s.sendJSONPayload(context.Background(), provider, data)
|
|
}
|
|
|
|
// ListTemplates returns all external notification templates stored in the database.
|
|
func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) {
|
|
var list []models.NotificationTemplate
|
|
if err := s.DB.Order("created_at desc").Find(&list).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// GetTemplate returns a single notification template by its ID.
|
|
func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) {
|
|
var t models.NotificationTemplate
|
|
if err := s.DB.Where("id = ?", id).First(&t).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// CreateTemplate stores a new notification template in the database.
|
|
func (s *NotificationService) CreateTemplate(t *models.NotificationTemplate) error {
|
|
return s.DB.Create(t).Error
|
|
}
|
|
|
|
// UpdateTemplate saves updates to an existing notification template.
|
|
func (s *NotificationService) UpdateTemplate(t *models.NotificationTemplate) error {
|
|
return s.DB.Save(t).Error
|
|
}
|
|
|
|
// DeleteTemplate removes a notification template by its ID.
|
|
func (s *NotificationService) DeleteTemplate(id string) error {
|
|
return s.DB.Delete(&models.NotificationTemplate{}, "id = ?", id).Error
|
|
}
|
|
|
|
// RenderTemplate renders a provider template with provided data and returns
|
|
// the rendered JSON string and the parsed object for previewing/validation.
|
|
func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]any) (resp string, parsed any, err error) {
|
|
// Built-in templates
|
|
const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}`
|
|
const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}`
|
|
|
|
tmplStr := p.Config
|
|
switch strings.ToLower(strings.TrimSpace(p.Template)) {
|
|
case "detailed":
|
|
tmplStr = detailedTemplate
|
|
case "minimal":
|
|
tmplStr = minimalTemplate
|
|
case "custom":
|
|
if tmplStr == "" {
|
|
tmplStr = minimalTemplate
|
|
}
|
|
default:
|
|
if tmplStr == "" {
|
|
tmplStr = minimalTemplate
|
|
}
|
|
}
|
|
|
|
// Parse and execute template with helper funcs
|
|
tmpl, err := template.New("webhook").Funcs(template.FuncMap{
|
|
"toJSON": func(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
},
|
|
}).Parse(tmplStr)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to parse webhook template: %w", err)
|
|
}
|
|
|
|
var body bytes.Buffer
|
|
if err := tmpl.Execute(&body, data); err != nil {
|
|
return "", nil, fmt.Errorf("failed to execute webhook template: %w", err)
|
|
}
|
|
|
|
// Validate produced JSON
|
|
if err := json.Unmarshal(body.Bytes(), &parsed); err != nil {
|
|
return body.String(), nil, fmt.Errorf("failed to parse rendered template: %w", err)
|
|
}
|
|
return body.String(), parsed, nil
|
|
}
|
|
|
|
// Provider Management
|
|
|
|
func (s *NotificationService) ListProviders() ([]models.NotificationProvider, error) {
|
|
var providers []models.NotificationProvider
|
|
result := s.DB.Find(&providers)
|
|
return providers, result.Error
|
|
}
|
|
|
|
func (s *NotificationService) CreateProvider(provider *models.NotificationProvider) error {
|
|
provider.Type = strings.ToLower(strings.TrimSpace(provider.Type))
|
|
if !isSupportedNotificationProviderType(provider.Type) {
|
|
return fmt.Errorf("unsupported provider type")
|
|
}
|
|
|
|
if err := validateDiscordProviderURLFunc(provider.Type, provider.URL); err != nil {
|
|
return err
|
|
}
|
|
|
|
if provider.Type != "gotify" {
|
|
provider.Token = ""
|
|
}
|
|
|
|
// Validate custom template before creating
|
|
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
|
|
// Provide a minimal preview payload
|
|
payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
|
|
if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
|
|
return fmt.Errorf("invalid custom template: %w", err)
|
|
}
|
|
}
|
|
return s.DB.Create(provider).Error
|
|
}
|
|
|
|
func (s *NotificationService) UpdateProvider(provider *models.NotificationProvider) error {
|
|
// Fetch existing provider to check type
|
|
var existing models.NotificationProvider
|
|
if err := s.DB.Where("id = ?", provider.ID).First(&existing).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Block type mutation for existing providers to avoid cross-provider token/schema confusion
|
|
if strings.TrimSpace(provider.Type) != "" && provider.Type != existing.Type {
|
|
return fmt.Errorf("cannot change provider type for existing providers")
|
|
}
|
|
provider.Type = existing.Type
|
|
|
|
if !isSupportedNotificationProviderType(provider.Type) {
|
|
return fmt.Errorf("unsupported provider type")
|
|
}
|
|
|
|
if err := validateDiscordProviderURLFunc(provider.Type, provider.URL); err != nil {
|
|
return err
|
|
}
|
|
|
|
if provider.Type == "gotify" {
|
|
if strings.TrimSpace(provider.Token) == "" {
|
|
provider.Token = existing.Token
|
|
}
|
|
} else {
|
|
provider.Token = ""
|
|
}
|
|
|
|
// Validate custom template before saving
|
|
if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" {
|
|
payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"}
|
|
if _, _, err := s.RenderTemplate(*provider, payload); err != nil {
|
|
return fmt.Errorf("invalid custom template: %w", err)
|
|
}
|
|
}
|
|
|
|
updates := map[string]any{
|
|
"name": provider.Name,
|
|
"type": provider.Type,
|
|
"url": provider.URL,
|
|
"token": provider.Token,
|
|
"config": provider.Config,
|
|
"template": provider.Template,
|
|
"enabled": provider.Enabled,
|
|
"notify_proxy_hosts": provider.NotifyProxyHosts,
|
|
"notify_remote_servers": provider.NotifyRemoteServers,
|
|
"notify_domains": provider.NotifyDomains,
|
|
"notify_certs": provider.NotifyCerts,
|
|
"notify_uptime": provider.NotifyUptime,
|
|
"notify_security_waf_blocks": provider.NotifySecurityWAFBlocks,
|
|
"notify_security_acl_denies": provider.NotifySecurityACLDenies,
|
|
"notify_security_rate_limit_hits": provider.NotifySecurityRateLimitHits,
|
|
"notify_security_crowdsec_decisions": provider.NotifySecurityCrowdSecDecisions,
|
|
}
|
|
|
|
return s.DB.Model(&models.NotificationProvider{}).
|
|
Where("id = ?", provider.ID).
|
|
Updates(updates).Error
|
|
}
|
|
|
|
func (s *NotificationService) DeleteProvider(id string) error {
|
|
return s.DB.Delete(&models.NotificationProvider{}, "id = ?", id).Error
|
|
}
|
|
|
|
// EnsureNotifyOnlyProviderMigration reconciles notification_providers rows to terminal state
|
|
// for Discord-only rollout. This migration is:
|
|
// - Idempotent: safe to run multiple times
|
|
// - Transactional: all updates succeed or all fail
|
|
// - Audited: logs all mutations with provider details
|
|
//
|
|
// Migration Policy:
|
|
// - Discord providers: marked as "migrated" with engine "notify_v1"
|
|
// - Non-Discord providers: marked as "deprecated" and disabled (non-dispatch, non-enable)
|
|
//
|
|
// Rollback Procedure:
|
|
// To rollback this migration:
|
|
// 1. Restore database from pre-migration backup (see data/backups/)
|
|
// 2. OR manually update providers: UPDATE notification_providers SET migration_state='pending', enabled=true WHERE type != 'discord'
|
|
// 3. Restart application with previous version
|
|
//
|
|
// This is invoked once at server boot.
|
|
func (s *NotificationService) EnsureNotifyOnlyProviderMigration(ctx context.Context) error {
|
|
// Begin transaction for atomicity
|
|
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var providers []models.NotificationProvider
|
|
if err := tx.Find(&providers).Error; err != nil {
|
|
return fmt.Errorf("failed to fetch notification providers for migration: %w", err)
|
|
}
|
|
|
|
// Pre-migration audit log
|
|
logger.Log().WithField("provider_count", len(providers)).
|
|
Info("Starting Discord-only provider migration")
|
|
|
|
now := time.Now()
|
|
for _, provider := range providers {
|
|
// Skip if already in terminal state (idempotency)
|
|
if provider.MigrationState == "migrated" || provider.MigrationState == "deprecated" {
|
|
continue
|
|
}
|
|
|
|
var updates map[string]any
|
|
|
|
if provider.Type == "discord" {
|
|
// Discord provider: mark as migrated
|
|
updates = map[string]any{
|
|
"engine": "notify_v1",
|
|
"migration_state": "migrated",
|
|
"migration_error": "",
|
|
"last_migrated_at": now,
|
|
}
|
|
} else {
|
|
// Non-Discord provider: mark as deprecated and disable
|
|
updates = map[string]any{
|
|
"migration_state": "deprecated",
|
|
"migration_error": "provider type not supported in discord-only rollout; delete and recreate as discord provider",
|
|
"enabled": false,
|
|
"last_migrated_at": now,
|
|
}
|
|
}
|
|
|
|
// Preserve legacy_url if URL is being set but legacy_url is empty (audit field)
|
|
if provider.LegacyURL == "" && provider.URL != "" {
|
|
updates["legacy_url"] = provider.URL
|
|
}
|
|
|
|
if err := tx.Model(&models.NotificationProvider{}).
|
|
Where("id = ?", provider.ID).
|
|
Updates(updates).Error; err != nil {
|
|
return fmt.Errorf("failed to migrate notification provider (id=%s, name=%q, type=%q): %w",
|
|
provider.ID, util.SanitizeForLog(provider.Name), provider.Type, err)
|
|
}
|
|
|
|
// Audit log for each mutated row
|
|
logger.Log().WithField("provider_id", provider.ID).
|
|
WithField("provider_name", util.SanitizeForLog(provider.Name)).
|
|
WithField("provider_type", provider.Type).
|
|
WithField("migration_state", updates["migration_state"]).
|
|
WithField("enabled", updates["enabled"]).
|
|
WithField("migration_timestamp", now.Format(time.RFC3339)).
|
|
Info("Migrated notification provider")
|
|
}
|
|
|
|
logger.Log().Info("Discord-only provider migration completed successfully")
|
|
return nil
|
|
})
|
|
}
|