diff --git a/pkg/channels/grafana_alertmanager/grafana_alertmanager.go b/pkg/channels/grafana_alertmanager/grafana_alertmanager.go new file mode 100644 index 000000000..3c85af20c --- /dev/null +++ b/pkg/channels/grafana_alertmanager/grafana_alertmanager.go @@ -0,0 +1,312 @@ +package grafana_alertmanager + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + // maxWebhookBodySize limits request body to prevent memory exhaustion. + maxWebhookBodySize = 1 << 20 // 1 MiB +) + +// Alert represents a single alert from Grafana Alertmanager. +type Alert struct { + Status string `json:"status"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + GeneratorURL string `json:"generatorURL"` + Fingerprint string `json:"fingerprint"` + SilenceURL string `json:"silenceURL,omitempty"` + DashboardURL string `json:"dashboardURL,omitempty"` + PanelURL string `json:"panelURL,omitempty"` + Values map[string]any `json:"values,omitempty"` + ValueString string `json:"valueString,omitempty"` +} + +// WebhookPayload represents the payload sent by Grafana Alertmanager. +type WebhookPayload struct { + Receiver string `json:"receiver"` + Status string `json:"status"` + Alerts []Alert `json:"alerts"` + GroupLabels map[string]string `json:"groupLabels"` + CommonLabels map[string]string `json:"commonLabels"` + CommonAnnotations map[string]string `json:"commonAnnotations"` + ExternalURL string `json:"externalURL"` + Version string `json:"version"` + GroupKey string `json:"groupKey"` + TruncatedAlerts int `json:"truncatedAlerts"` + OrgID int `json:"orgId"` + Title string `json:"title"` + State string `json:"state"` + Message string `json:"message"` +} + +// GrafanaAlertmanagerChannel implements a webhook endpoint for Grafana Alertmanager. +type GrafanaAlertmanagerChannel struct { + *channels.BaseChannel + config config.GrafanaAlertmanagerConfig +} + +// NewGrafanaAlertmanagerChannel creates a new Grafana Alertmanager channel. +// Returns an error if allow_from is restrictive but no secret is configured, +// since allow_from cannot meaningfully restrict webhook callers (the senderID +// is always "grafana-alertmanager"). Use secret for authentication instead. +func NewGrafanaAlertmanagerChannel( + cfg config.GrafanaAlertmanagerConfig, + messageBus *bus.MessageBus, +) (*GrafanaAlertmanagerChannel, error) { + // Validate: if allow_from is restrictive, secret must be configured + // because allow_from checks senderID which is always "grafana-alertmanager" + if !isOpenAccess(cfg.AllowFrom) && cfg.Secret.String() == "" { + return nil, fmt.Errorf( + "grafana_alertmanager: secret is required when allow_from is restrictive; " + + "allow_from cannot restrict webhook callers (use secret for authentication)", + ) + } + + base := channels.NewBaseChannel("grafana_alertmanager", cfg, messageBus, cfg.AllowFrom) + + return &GrafanaAlertmanagerChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// isOpenAccess returns true if allowFrom permits open access (empty or contains "*"). +func isOpenAccess(allowFrom []string) bool { + if len(allowFrom) == 0 { + return true + } + for _, v := range allowFrom { + if v == "*" { + return true + } + } + return false +} + +// Start initializes the channel. +func (c *GrafanaAlertmanagerChannel) Start(ctx context.Context) error { + logger.InfoC("grafana_alertmanager", "Starting Grafana Alertmanager channel (Webhook Mode)") + c.SetRunning(true) + return nil +} + +// Stop gracefully stops the channel. +func (c *GrafanaAlertmanagerChannel) Stop(ctx context.Context) error { + logger.InfoC("grafana_alertmanager", "Stopping Grafana Alertmanager channel") + c.SetRunning(false) + return nil +} + +// Send is a no-op for this input-only channel. +// Returns nil, nil to avoid triggering retry logic in the channel manager. +func (c *GrafanaAlertmanagerChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + // This is an input-only channel, no outbound messages supported. + // Return nil, nil to signal "nothing to do" without triggering retries. + return nil, nil +} + +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *GrafanaAlertmanagerChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/grafana-alertmanager" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *GrafanaAlertmanagerChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) + if err != nil { + logger.ErrorCF("grafana_alertmanager", "Failed to read request body", map[string]any{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + if int64(len(body)) > maxWebhookBodySize { + logger.WarnC("grafana_alertmanager", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + return + } + + // Verify signature if secret is configured + if c.config.Secret.String() != "" { + // Grafana Alerting sends signatures in "X-Grafana-Alerting-Signature" header + signature := r.Header.Get("X-Grafana-Alerting-Signature") + if !c.verifySignature(body, signature) { + logger.WarnC("grafana_alertmanager", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + + var payload WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + logger.ErrorCF("grafana_alertmanager", "Failed to parse webhook payload", map[string]any{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + logger.InfoCF("grafana_alertmanager", "Received alert webhook", map[string]any{ + "status": payload.Status, + "alert_count": len(payload.Alerts), + "receiver": payload.Receiver, + "title": payload.Title, + }) + + // Format the alert message + content := c.formatAlertMessage(&payload) + + // Determine chat ID - use configured chat_id or generate from receiver + chatID := c.config.ChatID + if chatID == "" { + chatID = "grafana:" + payload.Receiver + } + + // Use receiver as sender ID + senderID := "grafana-alertmanager" + + // Publish the inbound message + c.HandleMessage( + r.Context(), + bus.Peer{Kind: "webhook", ID: payload.Receiver}, + payload.GroupKey, // messageID + senderID, + chatID, + content, + nil, // no media + nil, // no metadata + ) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +} + +// verifySignature verifies the HMAC-SHA256 signature of the webhook payload. +// The signature is expected to be a hex-encoded string, optionally prefixed with "sha256=". +func (c *GrafanaAlertmanagerChannel) verifySignature(body []byte, signature string) bool { + if signature == "" { + return false + } + + // Remove "sha256=" prefix if present + signature = strings.TrimPrefix(signature, "sha256=") + + // Decode the provided signature from hex + providedMAC, err := hex.DecodeString(signature) + if err != nil { + return false + } + + // Compute expected MAC + mac := hmac.New(sha256.New, []byte(c.config.Secret.String())) + mac.Write(body) + expectedMAC := mac.Sum(nil) + + // Constant-time comparison (only works correctly when lengths match) + if len(providedMAC) != len(expectedMAC) { + return false + } + + return hmac.Equal(providedMAC, expectedMAC) +} + +// formatAlertMessage formats the alert payload into a readable message. +func (c *GrafanaAlertmanagerChannel) formatAlertMessage(payload *WebhookPayload) string { + var sb strings.Builder + + // Title and status + status := strings.ToUpper(payload.Status) + if payload.Title != "" { + sb.WriteString(fmt.Sprintf("**[%s] %s**\n\n", status, payload.Title)) + } else { + sb.WriteString(fmt.Sprintf("**[%s] Grafana Alert**\n\n", status)) + } + + // Message if present + if payload.Message != "" { + sb.WriteString(payload.Message) + sb.WriteString("\n\n") + } + + // Alert details + for i, alert := range payload.Alerts { + if i > 0 { + sb.WriteString("\n---\n\n") + } + + // Alert name from labels + alertName := alert.Labels["alertname"] + if alertName == "" { + alertName = fmt.Sprintf("Alert %d", i+1) + } + sb.WriteString(fmt.Sprintf("**%s** (%s)\n", alertName, alert.Status)) + + // Summary and description from annotations + if summary := alert.Annotations["summary"]; summary != "" { + sb.WriteString(fmt.Sprintf("Summary: %s\n", summary)) + } + if description := alert.Annotations["description"]; description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", description)) + } + + // Value string if present + if alert.ValueString != "" { + sb.WriteString(fmt.Sprintf("Value: %s\n", alert.ValueString)) + } + + // Key labels (excluding alertname) + var labels []string + for k, v := range alert.Labels { + if k != "alertname" && k != "__alert_rule_uid__" && k != "__alert_rule_namespace_uid__" { + labels = append(labels, fmt.Sprintf("%s=%s", k, v)) + } + } + if len(labels) > 0 { + sb.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(labels, ", "))) + } + + // Links + if alert.DashboardURL != "" { + sb.WriteString(fmt.Sprintf("Dashboard: %s\n", alert.DashboardURL)) + } + if alert.PanelURL != "" { + sb.WriteString(fmt.Sprintf("Panel: %s\n", alert.PanelURL)) + } + if alert.SilenceURL != "" { + sb.WriteString(fmt.Sprintf("Silence: %s\n", alert.SilenceURL)) + } + } + + // External URL + if payload.ExternalURL != "" { + sb.WriteString(fmt.Sprintf("\nGrafana: %s\n", payload.ExternalURL)) + } + + return sb.String() +} diff --git a/pkg/channels/grafana_alertmanager/grafana_alertmanager_test.go b/pkg/channels/grafana_alertmanager/grafana_alertmanager_test.go new file mode 100644 index 000000000..71917e75b --- /dev/null +++ b/pkg/channels/grafana_alertmanager/grafana_alertmanager_test.go @@ -0,0 +1,429 @@ +package grafana_alertmanager + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewGrafanaAlertmanagerChannel_RequiresSecretWhenAllowFromRestrictive(t *testing.T) { + tests := []struct { + name string + allowFrom []string + secret string + wantErr bool + }{ + { + name: "open access (empty allow_from) without secret is allowed", + allowFrom: nil, + secret: "", + wantErr: false, + }, + { + name: "open access (wildcard) without secret is allowed", + allowFrom: []string{"*"}, + secret: "", + wantErr: false, + }, + { + name: "restrictive allow_from without secret fails", + allowFrom: []string{"specific-sender"}, + secret: "", + wantErr: true, + }, + { + name: "restrictive allow_from with secret is allowed", + allowFrom: []string{"specific-sender"}, + secret: "mysecret", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.GrafanaAlertmanagerConfig{ + Enabled: true, + AllowFrom: tt.allowFrom, + } + if tt.secret != "" { + cfg.Secret = *config.NewSecureString(tt.secret) + } + msgBus := bus.NewMessageBus() + + _, err := NewGrafanaAlertmanagerChannel(cfg, msgBus) + + if (err != nil) != tt.wantErr { + t.Errorf("NewGrafanaAlertmanagerChannel() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestWebhookRejectsNonPostMethod(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{} + + req := httptest.NewRequest(http.MethodGet, "/webhook/grafana-alertmanager", nil) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestWebhookRejectsOversizedBody(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", bytes.NewReader(oversized)) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookAcceptsMaxBodySize(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{ + config: config.GrafanaAlertmanagerConfig{ + Secret: *config.NewSecureString("testsecret"), + }, + } + + // Create a body exactly at the limit + body := bytes.Repeat([]byte("A"), maxWebhookBodySize) + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + // Should fail on signature check, not body size + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d (forbidden due to missing signature), got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{ + config: config.GrafanaAlertmanagerConfig{ + Secret: *config.NewSecureString("testsecret"), + }, + } + + body := `{"status":"firing","alerts":[]}` + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsInvalidSignature(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{ + config: config.GrafanaAlertmanagerConfig{ + Secret: *config.NewSecureString("testsecret"), + }, + } + + body := `{"status":"firing","alerts":[]}` + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + req.Header.Set("X-Grafana-Alerting-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsInvalidJSON(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{ + config: config.GrafanaAlertmanagerConfig{}, // no secret, open access + } + + body := `{invalid json` + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestWebhookAcceptsValidPayloadWithoutSecret(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.GrafanaAlertmanagerConfig{ + Enabled: true, + ChatID: "test-chat", + } + + ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus) + if err != nil { + t.Fatalf("failed to create channel: %v", err) + } + + body := `{ + "status": "firing", + "receiver": "test-receiver", + "alerts": [ + { + "status": "firing", + "labels": {"alertname": "TestAlert", "severity": "critical"}, + "annotations": {"summary": "Test summary", "description": "Test description"} + } + ], + "groupKey": "test-group", + "title": "Test Alert Title" + }` + + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if rec.Body.String() != `{"status":"ok"}` { + t.Errorf("expected body %q, got %q", `{"status":"ok"}`, rec.Body.String()) + } +} + +func TestWebhookAcceptsValidPayloadWithValidSignature(t *testing.T) { + secret := "testsecret123" + msgBus := bus.NewMessageBus() + cfg := config.GrafanaAlertmanagerConfig{ + Enabled: true, + Secret: *config.NewSecureString(secret), + ChatID: "test-chat", + } + + ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus) + if err != nil { + t.Fatalf("failed to create channel: %v", err) + } + + body := `{"status":"firing","receiver":"test","alerts":[],"groupKey":"g1","title":"Test"}` + + // Compute valid signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + signature := hex.EncodeToString(mac.Sum(nil)) + + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + req.Header.Set("X-Grafana-Alerting-Signature", signature) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestWebhookAcceptsSignatureWithSha256Prefix(t *testing.T) { + secret := "testsecret123" + msgBus := bus.NewMessageBus() + cfg := config.GrafanaAlertmanagerConfig{ + Enabled: true, + Secret: *config.NewSecureString(secret), + ChatID: "test-chat", + } + + ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus) + if err != nil { + t.Fatalf("failed to create channel: %v", err) + } + + body := `{"status":"firing","receiver":"test","alerts":[],"groupKey":"g1","title":"Test"}` + + // Compute valid signature with sha256= prefix + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body)) + req.Header.Set("X-Grafana-Alerting-Signature", signature) + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestVerifySignature(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{ + config: config.GrafanaAlertmanagerConfig{ + Secret: *config.NewSecureString("mysecret"), + }, + } + + body := []byte("test body content") + mac := hmac.New(sha256.New, []byte("mysecret")) + mac.Write(body) + validSig := hex.EncodeToString(mac.Sum(nil)) + + tests := []struct { + name string + body []byte + signature string + want bool + }{ + { + name: "valid signature", + body: body, + signature: validSig, + want: true, + }, + { + name: "valid signature with sha256 prefix", + body: body, + signature: "sha256=" + validSig, + want: true, + }, + { + name: "empty signature", + body: body, + signature: "", + want: false, + }, + { + name: "invalid hex", + body: body, + signature: "notvalidhex!!!", + want: false, + }, + { + name: "wrong signature", + body: body, + signature: strings.Repeat("ab", 32), // valid hex but wrong value + want: false, + }, + { + name: "wrong length", + body: body, + signature: "abcd", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ch.verifySignature(tt.body, tt.signature) + if got != tt.want { + t.Errorf("verifySignature() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFormatAlertMessage(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{} + + payload := &WebhookPayload{ + Status: "firing", + Receiver: "test-receiver", + Title: "High CPU Usage", + Message: "Server is experiencing high CPU load", + Alerts: []Alert{ + { + Status: "firing", + Labels: map[string]string{ + "alertname": "HighCPU", + "severity": "critical", + "host": "server1", + }, + Annotations: map[string]string{ + "summary": "CPU usage above 90%", + "description": "The server CPU has been above 90% for 5 minutes", + }, + ValueString: "cpu_usage: 95%", + DashboardURL: "http://grafana/dashboard", + PanelURL: "http://grafana/panel", + SilenceURL: "http://grafana/silence", + }, + }, + ExternalURL: "http://grafana", + } + + result := ch.formatAlertMessage(payload) + + // Check key elements are present + checks := []string{ + "**[FIRING] High CPU Usage**", + "Server is experiencing high CPU load", + "**HighCPU** (firing)", + "Summary: CPU usage above 90%", + "Description: The server CPU has been above 90% for 5 minutes", + "Value: cpu_usage: 95%", + "severity=critical", + "host=server1", + "Dashboard: http://grafana/dashboard", + "Panel: http://grafana/panel", + "Silence: http://grafana/silence", + "Grafana: http://grafana", + } + + for _, check := range checks { + if !strings.Contains(result, check) { + t.Errorf("formatted message missing %q\nGot:\n%s", check, result) + } + } +} + +func TestFormatAlertMessage_DefaultTitle(t *testing.T) { + ch := &GrafanaAlertmanagerChannel{} + + payload := &WebhookPayload{ + Status: "resolved", + Alerts: []Alert{}, + } + + result := ch.formatAlertMessage(payload) + + if !strings.Contains(result, "**[RESOLVED] Grafana Alert**") { + t.Errorf("expected default title, got: %s", result) + } +} + +func TestIsOpenAccess(t *testing.T) { + tests := []struct { + name string + allowFrom []string + want bool + }{ + {"nil", nil, true}, + {"empty", []string{}, true}, + {"wildcard only", []string{"*"}, true}, + {"wildcard with others", []string{"foo", "*", "bar"}, true}, + {"specific values", []string{"foo", "bar"}, false}, + {"single specific", []string{"foo"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isOpenAccess(tt.allowFrom); got != tt.want { + t.Errorf("isOpenAccess(%v) = %v, want %v", tt.allowFrom, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/grafana_alertmanager/init.go b/pkg/channels/grafana_alertmanager/init.go new file mode 100644 index 000000000..6bbe10959 --- /dev/null +++ b/pkg/channels/grafana_alertmanager/init.go @@ -0,0 +1,16 @@ +package grafana_alertmanager + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory( + "grafana_alertmanager", + func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewGrafanaAlertmanagerChannel(cfg.Channels.GrafanaAlertmanager, b) + }, + ) +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c4326fda0..f23c8f5da 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -443,6 +443,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { } } + if channels.GrafanaAlertmanager.Enabled { + m.initChannel("grafana_alertmanager", "Grafana Alertmanager") + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index b54facda4..de3082969 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -69,6 +69,8 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { webhooks[name] = target.WebhookURL.String() } value["webhooks"] = webhooks + case "grafana_alertmanager": + value["secret"] = ch.GrafanaAlertmanager.Secret.String() } } @@ -182,4 +184,7 @@ func updateKeys(newcfg, old *config.ChannelsConfig) { } } } + if newcfg.GrafanaAlertmanager.Enabled { + newcfg.GrafanaAlertmanager.Secret = old.GrafanaAlertmanager.Secret + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index fd4466b8c..328172019 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -296,24 +296,25 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` - Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` - Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` - Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` - MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` - QQ QQConfig `json:"qq" yaml:"qq,omitempty"` - DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` - Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` - Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` - LINE LINEConfig `json:"line" yaml:"line,omitempty"` - OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` - WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` - Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` - Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` - PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` - IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` - VK VKConfig `json:"vk" yaml:"vk,omitempty"` - TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"` + WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` + Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` + Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` + Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` + MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` + QQ QQConfig `json:"qq" yaml:"qq,omitempty"` + DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` + Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` + Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` + LINE LINEConfig `json:"line" yaml:"line,omitempty"` + OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` + WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` + Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` + Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` + PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` + IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` + VK VKConfig `json:"vk" yaml:"vk,omitempty"` + TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"` + GrafanaAlertmanager GrafanaAlertmanagerConfig `json:"grafana_alertmanager" yaml:"grafana_alertmanager,omitempty"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -596,6 +597,17 @@ type TeamsWebhookTarget struct { Title string `json:"title,omitempty" yaml:"-"` } +// GrafanaAlertmanagerConfig configures the Grafana Alertmanager webhook channel. +// This channel exposes a webhook endpoint that receives alerts from Grafana Alertmanager. +type GrafanaAlertmanagerConfig struct { + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_ENABLED"` + WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_WEBHOOK_PATH"` + Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_ALLOW_FROM"` + ChatID string `json:"chat_id" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_CHAT_ID"` + ForcedSkills []string `json:"forced_skills" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_FORCED_SKILLS"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index be8f9d1c8..b75402c21 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -21,6 +21,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/grafana_alertmanager" _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"