From 73c2b8ef425335fb540046b7858d6446bf01fad3 Mon Sep 17 00:00:00 2001 From: elafkaihi Date: Wed, 23 Apr 2025 22:54:40 +0200 Subject: [PATCH 1/2] add a new feauture of sending slack notifications --- README.md | 66 +++++++- internal/types/types.go | 4 +- pkg/certmanagersync/certmanagersync.go | 136 ++++++++++++++++- stores/slack/slack.go | 202 +++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 stores/slack/slack.go diff --git a/README.md b/README.md index ec185ee..c2305d3 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,69 @@ cert_manager_sync_status{namespace="cert-manager",secret="example",store="acm",s Setting `ENABLE_METRICS=false` will disable the metrics server. +## Slack Notifications + +cert-manager-sync can send notifications to Slack when a certificate is successfully synced to a store. This is useful for monitoring certificate updates and renewals. + +### Enabling Slack Notifications + +There are two ways to configure Slack notifications: + +#### 1. Using Annotations on the Secret + +Add the following annotations to your Kubernetes TLS secret: + +```yaml +cert-manager-sync.lestak.sh/slack-notify-enabled: "true" +cert-manager-sync.lestak.sh/slack-webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" +cert-manager-sync.lestak.sh/slack-channel: "#certificate-updates" +cert-manager-sync.lestak.sh/slack-username: "cert-manager-sync" +``` + +#### 2. Using a Kubernetes Secret for Webhook URL + +If you prefer not to expose your Slack webhook URL in annotations, you can store it in a separate Kubernetes secret: + +```bash +kubectl -n cert-manager \ + create secret generic slack-webhook-secret \ + --from-literal webhook_url=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX +``` + +Then reference this secret in your TLS secret annotations: + +```yaml +cert-manager-sync.lestak.sh/slack-notify-enabled: "true" +cert-manager-sync.lestak.sh/slack-secret-name: "slack-webhook-secret" +cert-manager-sync.lestak.sh/slack-channel: "#certificate-updates" +cert-manager-sync.lestak.sh/slack-username: "cert-manager-sync" +``` + +### Slack Messages + +Notifications will be sent to the configured Slack channel in the following cases: + +1. **Success Notifications**: When a certificate is successfully synced to a store + - Green colored attachment + - Lock emoji (:lock:) + - Success message with store details + +2. **Failure Notifications**: When a certificate sync fails + - Red colored attachment + - Warning emoji (:warning:) + - Error message with failure details + +Each notification includes: +- Certificate name +- Namespace +- Store type (ACM, CloudFlare, etc.) +- Timestamp +- Success or error message + +### Multiple Notifications + +You can configure different notification settings for different certificates by using the appropriate annotations on each TLS secret. + ### Error Logging The following log filter will display just errors syncing certificates: @@ -444,4 +507,5 @@ The following fields are included in the sync error log message: ```bash level=error action=SyncSecretToStore namespace=cert-manager secret=example store=acm error="error message" -``` \ No newline at end of file +``` + diff --git a/internal/types/types.go b/internal/types/types.go index bc646ec..0bd7ed4 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -18,6 +18,7 @@ const ( IncapsulaStoreType StoreType = "incapsula" ThreatxStoreType StoreType = "threatx" VaultStoreType StoreType = "vault" + SlackStoreType StoreType = "slack" ) var EnabledStores = []StoreType{ @@ -30,6 +31,7 @@ var EnabledStores = []StoreType{ IncapsulaStoreType, ThreatxStoreType, VaultStoreType, + SlackStoreType, } func IsValidStoreType(storeType string) bool { @@ -39,4 +41,4 @@ func IsValidStoreType(storeType string) bool { } } return false -} +} \ No newline at end of file diff --git a/pkg/certmanagersync/certmanagersync.go b/pkg/certmanagersync/certmanagersync.go index 98e03e6..f8fd395 100644 --- a/pkg/certmanagersync/certmanagersync.go +++ b/pkg/certmanagersync/certmanagersync.go @@ -18,6 +18,7 @@ import ( "github.com/robertlestak/cert-manager-sync/stores/gcpcm" "github.com/robertlestak/cert-manager-sync/stores/heroku" "github.com/robertlestak/cert-manager-sync/stores/incapsula" + "github.com/robertlestak/cert-manager-sync/stores/slack" "github.com/robertlestak/cert-manager-sync/stores/threatx" "github.com/robertlestak/cert-manager-sync/stores/vault" log "github.com/sirupsen/logrus" @@ -31,6 +32,12 @@ type RemoteStore interface { FromConfig(config tlssecret.GenericSecretSyncConfig) error } +// SlackNotifier is an interface that can be implemented by stores that support Slack notifications +type SlackNotifier interface { + NotifySuccess(storeType, secretName, namespace, successMsg string) error + NotifyFailure(storeType, secretName, namespace, errorMsg string) error +} + func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) { l := log.WithFields(log.Fields{ "action": "NewStore", @@ -56,6 +63,8 @@ func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) { store = &threatx.ThreatXStore{} case cmtypes.VaultStoreType: store = &vault.VaultStore{} + case cmtypes.SlackStoreType: + store = &slack.SlackStore{} default: return nil, cmtypes.ErrInvalidStoreType } @@ -177,6 +186,119 @@ func calculateNextRetryTime(secret *corev1.Secret) time.Time { return nextRetryTime } +// getSlackConfig extracts Slack configuration from annotations if present +func getSlackConfig(s *corev1.Secret) (*slack.SlackStore, error) { + if s.Annotations == nil { + return nil, nil + } + + // Check if Slack notifications are enabled + slackEnabled := s.Annotations[state.OperatorName+"/slack-notify-enabled"] + if slackEnabled != "true" { + return nil, nil + } + + // Get the Slack configuration + slackConfig := &slack.SlackStore{ + SecretNamespace: s.Namespace, + } + + if webhookURL := s.Annotations[state.OperatorName+"/slack-webhook-url"]; webhookURL != "" { + slackConfig.WebhookURL = webhookURL + } + + if secretName := s.Annotations[state.OperatorName+"/slack-secret-name"]; secretName != "" { + slackConfig.SecretName = secretName + } + + if channel := s.Annotations[state.OperatorName+"/slack-channel"]; channel != "" { + slackConfig.ChannelName = channel + } + + if username := s.Annotations[state.OperatorName+"/slack-username"]; username != "" { + slackConfig.Username = username + } else { + slackConfig.Username = "cert-manager-sync" + } + + // If neither webhook URL nor secret name is provided, we can't send notifications + if slackConfig.WebhookURL == "" && slackConfig.SecretName == "" { + return nil, fmt.Errorf("either slack-webhook-url or slack-secret-name annotation is required for Slack notifications") + } + + return slackConfig, nil +} + +// sendSlackSuccessNotification sends a success notification to Slack if configured +func sendSlackSuccessNotification(s *corev1.Secret, cert *tlssecret.Certificate, storeType, successMsg string) { + l := log.WithFields(log.Fields{ + "action": "sendSlackSuccessNotification", + "namespace": s.Namespace, + "name": s.Name, + "storeType": storeType, + }) + + slackConfig, err := getSlackConfig(s) + if err != nil { + l.WithError(err).Warn("Failed to get Slack configuration, skipping notification") + return + } + + if slackConfig == nil { + // Slack notifications not enabled or configured + return + } + + notifier := &slack.SlackStore{ + WebhookURL: slackConfig.WebhookURL, + ChannelName: slackConfig.ChannelName, + Username: slackConfig.Username, + SecretName: slackConfig.SecretName, + SecretNamespace: slackConfig.SecretNamespace, + } + + if err := notifier.NotifySuccess(storeType, cert.SecretName, cert.Namespace, successMsg); err != nil { + l.WithError(err).Warn("Failed to send Slack success notification") + } else { + l.Debug("Slack success notification sent successfully") + } +} + +// sendSlackFailureNotification sends a failure notification to Slack if configured +func sendSlackFailureNotification(s *corev1.Secret, cert *tlssecret.Certificate, storeType, errorMsg string) { + l := log.WithFields(log.Fields{ + "action": "sendSlackFailureNotification", + "namespace": s.Namespace, + "name": s.Name, + "storeType": storeType, + }) + + slackConfig, err := getSlackConfig(s) + if err != nil { + l.WithError(err).Warn("Failed to get Slack configuration, skipping notification") + return + } + + if slackConfig == nil { + // Slack notifications not enabled or configured + return + } + + notifier := &slack.SlackStore{ + WebhookURL: slackConfig.WebhookURL, + ChannelName: slackConfig.ChannelName, + Username: slackConfig.Username, + SecretName: slackConfig.SecretName, + SecretNamespace: slackConfig.SecretNamespace, + } + + if err := notifier.NotifyFailure(storeType, cert.SecretName, cert.Namespace, errorMsg); err != nil { + l.WithError(err).Warn("Failed to send Slack failure notification") + } else { + l.Debug("Slack failure notification sent successfully") + } +} + func HandleSecret(s *corev1.Secret) error { l := log.WithFields(log.Fields{ "action": "HandleSecret", @@ -225,6 +347,11 @@ func HandleSecret(s *corev1.Secret) error { l.WithError(err).Errorf("Sync error") metrics.SetFailure(s.Namespace, s.Name, sync.Store) state.EventRecorder.Event(s, corev1.EventTypeWarning, "SyncFailed", fmt.Sprintf("Secret sync failed to store %s", sync.Store)) + + // Send failure notification to Slack if enabled + errorMsg := fmt.Sprintf("Failed to sync certificate to %s: %v", sync.Store, err) + sendSlackFailureNotification(s, cert, sync.Store, errorMsg) + errs = append(errs, err) continue } @@ -232,6 +359,13 @@ func HandleSecret(s *corev1.Secret) error { l.WithField("updates", updates).Debug("synced with updates") } sync.Updates = updates + + // Send success notification to Slack if enabled + successMsg := fmt.Sprintf("Certificate successfully synced to %s", sync.Store) + sendSlackSuccessNotification(s, cert, sync.Store, successMsg) + + // Set success metrics + metrics.SetSuccess(s.Namespace, s.Name, sync.Store) } patchAnnotations := make(map[string]string) if s.Annotations != nil { @@ -301,4 +435,4 @@ func HandleSecret(s *corev1.Secret) error { eventMsg := fmt.Sprintf("Secret synced to %d store%s", len(cert.Syncs), scf) state.EventRecorder.Event(s, corev1.EventTypeNormal, "Synced", eventMsg) return nil -} +} \ No newline at end of file diff --git a/stores/slack/slack.go b/stores/slack/slack.go new file mode 100644 index 0000000..d179ad7 --- /dev/null +++ b/stores/slack/slack.go @@ -0,0 +1,202 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/robertlestak/cert-manager-sync/pkg/state" + "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type SlackStore struct { + WebhookURL string + ChannelName string + Username string + SecretName string + SecretNamespace string +} + +type SlackMessage struct { + Channel string `json:"channel,omitempty"` + Username string `json:"username,omitempty"` + Text string `json:"text,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + Attachments []SlackAttachment `json:"attachments,omitempty"` +} + +type SlackAttachment struct { + Fallback string `json:"fallback,omitempty"` + Color string `json:"color,omitempty"` + Pretext string `json:"pretext,omitempty"` + AuthorName string `json:"author_name,omitempty"` + AuthorLink string `json:"author_link,omitempty"` + AuthorIcon string `json:"author_icon,omitempty"` + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Text string `json:"text,omitempty"` + Fields []SlackAttachmentField `json:"fields,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + Footer string `json:"footer,omitempty"` + FooterIcon string `json:"footer_icon,omitempty"` + Timestamp int64 `json:"ts,omitempty"` +} + +type SlackAttachmentField struct { + Title string `json:"title,omitempty"` + Value string `json:"value,omitempty"` + Short bool `json:"short,omitempty"` +} + +func (s *SlackStore) GetWebhookURL(ctx context.Context) error { + if s.WebhookURL != "" { + return nil + } + + gopt := metav1.GetOptions{} + sc, err := state.KubeClient.CoreV1().Secrets(s.SecretNamespace).Get(ctx, s.SecretName, gopt) + if err != nil { + return err + } + if sc.Data["webhook_url"] == nil { + return fmt.Errorf("webhook_url not found in secret %s/%s", s.SecretNamespace, s.SecretName) + } + s.WebhookURL = string(sc.Data["webhook_url"]) + return nil +} + +func (s *SlackStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { + l := log.WithFields(log.Fields{ + "action": "FromConfig", + }) + l.Debugf("FromConfig") + + if c.Config["webhook-url"] != "" { + s.WebhookURL = c.Config["webhook-url"] + } + if c.Config["secret-name"] != "" { + s.SecretName = c.Config["secret-name"] + } + if c.Config["channel"] != "" { + s.ChannelName = c.Config["channel"] + } + if c.Config["username"] != "" { + s.Username = c.Config["username"] + } else { + s.Username = "cert-manager-sync" + } + + // Handle the namespace/secretname format + if strings.Contains(s.SecretName, "/") { + s.SecretNamespace = strings.Split(s.SecretName, "/")[0] + s.SecretName = strings.Split(s.SecretName, "/")[1] + } + + return nil +} + +func (s *SlackStore) SendNotification(storeType, secretName, namespace, successMsg string) error { + l := log.WithFields(log.Fields{ + "action": "SendNotification", + "store": "slack", + "storeType": storeType, + }) + l.Debugf("Sending notification to Slack") + + ctx := context.Background() + if err := s.GetWebhookURL(ctx); err != nil { + l.WithError(err).Errorf("Failed to get webhook URL") + return err + } + + // Build the message + attachment := SlackAttachment{ + Color: "#36a64f", // Green for success + Title: fmt.Sprintf("Certificate Sync Success: %s", secretName), + Text: successMsg, + Timestamp: time.Now().Unix(), + Fields: []SlackAttachmentField{ + { + Title: "Store Type", + Value: storeType, + Short: true, + }, + { + Title: "Secret Name", + Value: secretName, + Short: true, + }, + { + Title: "Namespace", + Value: namespace, + Short: true, + }, + }, + Footer: "cert-manager-sync", + } + + message := SlackMessage{ + Username: s.Username, + IconEmoji: ":lock:", + Attachments: []SlackAttachment{attachment}, + } + + // Use channel from config if specified + if s.ChannelName != "" { + message.Channel = s.ChannelName + } + + payload, err := json.Marshal(message) + if err != nil { + l.WithError(err).Errorf("Failed to marshal Slack message") + return err + } + + resp, err := http.Post(s.WebhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + l.WithError(err).Errorf("Failed to send Slack message") + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack API returned non-200 status code: %d", resp.StatusCode) + } + + l.Info("Slack notification sent successfully") + return nil +} + +// Sync implements the Store interface but for the Slack store, it just returns success +// It's meant to be used as a notification endpoint, not a certificate store +func (s *SlackStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { + s.SecretNamespace = c.Namespace + l := log.WithFields(log.Fields{ + "action": "Sync", + "store": "slack", + "secretName": c.SecretName, + "secretNamespace": c.Namespace, + }) + l.Debug("Slack notification sync called, but this is typically used via NotifySuccess") + + // For the slack store, we don't actually sync a certificate + // This is just here to satisfy the Store interface + return nil, nil +} + +// NotifySuccess sends a notification about a successful sync +func (s *SlackStore) NotifySuccess(storeType, secretName, namespace, successMsg string) error { + return s.SendNotification(storeType, secretName, namespace, successMsg, true) +} + +// NotifyFailure sends a notification about a failed sync +func (s *SlackStore) NotifyFailure(storeType, secretName, namespace, errorMsg string) error { + return s.SendNotification(storeType, secretName, namespace, errorMsg, false) +} \ No newline at end of file From 6dafea0b280694ff26e76f9f780e0def5fdea291 Mon Sep 17 00:00:00 2001 From: elafkaihi Date: Wed, 23 Apr 2025 23:57:38 +0200 Subject: [PATCH 2/2] new fixes --- Dockerfile | 6 +++--- stores/slack/slack.go | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4fcd5a7..5ba0535 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.5 as builder +FROM golang:1.22.5 AS builder WORKDIR /app @@ -10,11 +10,11 @@ RUN go mod download && go mod verify RUN CGO_ENABLED=0 go build -o /app/cert-manager-sync cmd/cert-manager-sync/*.go -FROM alpine:3.21 as alpine +FROM alpine:3.21 AS alpine RUN apk add -U --no-cache ca-certificates -FROM scratch as app +FROM scratch AS app WORKDIR /app diff --git a/stores/slack/slack.go b/stores/slack/slack.go index d179ad7..101a637 100644 --- a/stores/slack/slack.go +++ b/stores/slack/slack.go @@ -102,11 +102,13 @@ func (s *SlackStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { return nil } -func (s *SlackStore) SendNotification(storeType, secretName, namespace, successMsg string) error { +// SendNotification sends a notification about a sync event +func (s *SlackStore) SendNotification(storeType, secretName, namespace, msg string, isSuccess bool) error { l := log.WithFields(log.Fields{ "action": "SendNotification", "store": "slack", "storeType": storeType, + "isSuccess": isSuccess, }) l.Debugf("Sending notification to Slack") @@ -117,10 +119,20 @@ func (s *SlackStore) SendNotification(storeType, secretName, namespace, successM } // Build the message + color := "#36a64f" // Green for success + title := fmt.Sprintf("Certificate Sync Success: %s", secretName) + icon := ":lock:" + + if !isSuccess { + color = "#e01e5a" // Red for failure + title = fmt.Sprintf("Certificate Sync Failed: %s", secretName) + icon = ":warning:" + } + attachment := SlackAttachment{ - Color: "#36a64f", // Green for success - Title: fmt.Sprintf("Certificate Sync Success: %s", secretName), - Text: successMsg, + Color: color, + Title: title, + Text: msg, Timestamp: time.Now().Unix(), Fields: []SlackAttachmentField{ { @@ -144,7 +156,7 @@ func (s *SlackStore) SendNotification(storeType, secretName, namespace, successM message := SlackMessage{ Username: s.Username, - IconEmoji: ":lock:", + IconEmoji: icon, Attachments: []SlackAttachment{attachment}, } @@ -174,6 +186,16 @@ func (s *SlackStore) SendNotification(storeType, secretName, namespace, successM return nil } +// NotifySuccess sends a notification about a successful sync +func (s *SlackStore) NotifySuccess(storeType, secretName, namespace, successMsg string) error { + return s.SendNotification(storeType, secretName, namespace, successMsg, true) +} + +// NotifyFailure sends a notification about a failed sync +func (s *SlackStore) NotifyFailure(storeType, secretName, namespace, errorMsg string) error { + return s.SendNotification(storeType, secretName, namespace, errorMsg, false) +} + // Sync implements the Store interface but for the Slack store, it just returns success // It's meant to be used as a notification endpoint, not a certificate store func (s *SlackStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { @@ -189,14 +211,4 @@ func (s *SlackStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { // For the slack store, we don't actually sync a certificate // This is just here to satisfy the Store interface return nil, nil -} - -// NotifySuccess sends a notification about a successful sync -func (s *SlackStore) NotifySuccess(storeType, secretName, namespace, successMsg string) error { - return s.SendNotification(storeType, secretName, namespace, successMsg, true) -} - -// NotifyFailure sends a notification about a failed sync -func (s *SlackStore) NotifyFailure(storeType, secretName, namespace, errorMsg string) error { - return s.SendNotification(storeType, secretName, namespace, errorMsg, false) } \ No newline at end of file