Merge 970886881c into 748ac58dd1
This commit is contained in:
commit
cd23badf2e
7 changed files with 797 additions and 18 deletions
312
pkg/channels/grafana_alertmanager/grafana_alertmanager.go
Normal file
312
pkg/channels/grafana_alertmanager/grafana_alertmanager.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
429
pkg/channels/grafana_alertmanager/grafana_alertmanager_test.go
Normal file
429
pkg/channels/grafana_alertmanager/grafana_alertmanager_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
pkg/channels/grafana_alertmanager/init.go
Normal file
16
pkg/channels/grafana_alertmanager/init.go
Normal file
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue