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{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"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()
|
webhooks[name] = target.WebhookURL.String()
|
||||||
}
|
}
|
||||||
value["webhooks"] = webhooks
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,7 @@ type ChannelsConfig struct {
|
||||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||||
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
|
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
|
||||||
TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,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.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -596,6 +597,17 @@ type TeamsWebhookTarget struct {
|
||||||
Title string `json:"title,omitempty" yaml:"-"`
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
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/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "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/irc"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue