Skip to content

Commit 5549a2a

Browse files
committed
feat: Implement webhook support for event notifications
This PR adds a complete webhook system for event-driven integrations. ## Features ### Webhook Management - Create/update/delete webhooks via REST API - Subscribe to specific event types (server, alert, security, discovery) - Custom headers support for authentication - Secret rotation endpoint ### Event Types - server.created, server.updated, server.deleted - server.status.up, server.status.down - alert.created, alert.resolved - security.scan.started, security.scan.completed - security.vulnerability.found - discovery.started, discovery.completed, discovery.server.found ### Security - HMAC-SHA256 signatures for payload verification - Timestamp validation (5-minute window) for replay attack prevention - Secrets never exposed after initial creation - Timing-safe signature comparison ### Delivery - Automatic retries with exponential backoff (1m, 5m, 15m, 1h, 2h) - Maximum 5 retry attempts - Delivery history tracking - Success/failure statistics - Test endpoint for webhook verification ### API Endpoints - POST /api/v1/webhooks - Create webhook - GET /api/v1/webhooks - List webhooks - GET /api/v1/webhooks/:id - Get webhook - PUT /api/v1/webhooks/:id - Update webhook - DELETE /api/v1/webhooks/:id - Delete webhook - POST /api/v1/webhooks/:id/rotate-secret - Rotate secret - POST /api/v1/webhooks/:id/test - Test webhook - GET /api/v1/webhooks/:id/deliveries - Get delivery history - GET /api/v1/webhooks/:id/stats - Get delivery statistics - GET /api/v1/webhook-events - List available events ### Files Added - internal/webhook/models.go - Data models and types - internal/webhook/repository.go - Database operations - internal/webhook/service.go - Business logic - internal/webhook/handler.go - HTTP handlers - internal/webhook/client.go - Verification utilities - internal/webhook/webhook_test.go - Unit tests - migrations/003_add_webhooks.sql - Database migration Closes #20
1 parent 877a71e commit 5549a2a

8 files changed

Lines changed: 1847 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Webhook Integration for main.go
2+
//
3+
// Add this import to the imports section:
4+
// "github.com/radhi1991/aran-mcp-sentinel/internal/webhook"
5+
//
6+
// Add this code after alertsHandler.RegisterRoutes(protected):
7+
8+
// Webhook initialization code:
9+
/*
10+
// Initialize webhook service
11+
webhookRepo := webhook.NewRepository(dbConn.DB)
12+
webhookService := webhook.NewService(webhookRepo, logger)
13+
webhookHandler := webhook.NewHandler(webhookService, logger)
14+
webhookHandler.RegisterRoutes(protected)
15+
16+
// Start webhook retry processor
17+
webhookCtx, webhookCancel := context.WithCancel(context.Background())
18+
defer webhookCancel()
19+
go func() {
20+
ticker := time.NewTicker(1 * time.Minute)
21+
defer ticker.Stop()
22+
for {
23+
select {
24+
case <-webhookCtx.Done():
25+
return
26+
case <-ticker.C:
27+
if err := webhookService.ProcessRetries(webhookCtx); err != nil {
28+
logger.Error("Failed to process webhook retries", zap.Error(err))
29+
}
30+
}
31+
}
32+
}()
33+
logger.Info("Started webhook retry processor", zap.Duration("interval", 1*time.Minute))
34+
*/
35+
36+
// Example usage from other services to trigger webhooks:
37+
/*
38+
// In monitoring/alerts handler when an alert is created:
39+
webhookService.TriggerEvent(ctx, orgID, webhook.EventAlertCreated, map[string]interface{}{
40+
"alert_id": alert.ID,
41+
"title": alert.Title,
42+
"severity": alert.Severity,
43+
"message": alert.Message,
44+
})
45+
46+
// In MCP handler when a server status changes:
47+
webhookService.TriggerEvent(ctx, orgID, webhook.EventServerStatusDown, map[string]interface{}{
48+
"server_id": server.ID,
49+
"server_name": server.Name,
50+
"status": "down",
51+
"error": errorMsg,
52+
})
53+
54+
// In security handler when a scan completes:
55+
webhookService.TriggerEvent(ctx, orgID, webhook.EventSecurityScanCompleted, map[string]interface{}{
56+
"scan_id": scan.ID,
57+
"vulnerabilities": vulnerabilityCount,
58+
"score": securityScore,
59+
})
60+
*/

backend/internal/webhook/client.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package webhook
2+
3+
import (
4+
"bytes"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"strconv"
13+
"time"
14+
)
15+
16+
// Client provides methods for verifying incoming webhooks and sending webhook-style requests
17+
type Client struct {
18+
secret string
19+
}
20+
21+
// NewClient creates a new webhook client for verification
22+
func NewClient(secret string) *Client {
23+
return &Client{secret: secret}
24+
}
25+
26+
// VerifyRequest verifies an incoming webhook request
27+
func (c *Client) VerifyRequest(r *http.Request) error {
28+
signature := r.Header.Get("X-Webhook-Signature")
29+
timestampStr := r.Header.Get("X-Webhook-Timestamp")
30+
31+
if signature == "" || timestampStr == "" {
32+
return fmt.Errorf("missing signature or timestamp headers")
33+
}
34+
35+
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
36+
if err != nil {
37+
return fmt.Errorf("invalid timestamp: %w", err)
38+
}
39+
40+
// Check timestamp is within 5 minutes (replay attack prevention)
41+
age := time.Since(time.Unix(timestamp, 0))
42+
if age > 5*time.Minute || age < -5*time.Minute {
43+
return fmt.Errorf("timestamp too old or too far in the future: %v", age)
44+
}
45+
46+
// Read body
47+
body, err := io.ReadAll(r.Body)
48+
if err != nil {
49+
return fmt.Errorf("failed to read body: %w", err)
50+
}
51+
// Restore body for downstream handlers
52+
r.Body = io.NopCloser(bytes.NewReader(body))
53+
54+
// Verify signature
55+
if !VerifySignature(c.secret, signature, timestamp, body) {
56+
return fmt.Errorf("signature verification failed")
57+
}
58+
59+
return nil
60+
}
61+
62+
// VerifyMiddleware returns an HTTP middleware that verifies webhook signatures
63+
func (c *Client) VerifyMiddleware(next http.Handler) http.Handler {
64+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+
if err := c.VerifyRequest(r); err != nil {
66+
http.Error(w, fmt.Sprintf("Webhook verification failed: %v", err), http.StatusUnauthorized)
67+
return
68+
}
69+
next.ServeHTTP(w, r)
70+
})
71+
}
72+
73+
// ParsePayload parses the webhook payload from the request
74+
func ParsePayload(r *http.Request) (*WebhookPayload, error) {
75+
var payload WebhookPayload
76+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
77+
return nil, fmt.Errorf("failed to decode payload: %w", err)
78+
}
79+
return &payload, nil
80+
}
81+
82+
// SignRequest signs an outgoing webhook request
83+
func SignRequest(secret string, req *http.Request, body []byte) {
84+
timestamp := time.Now().Unix()
85+
signature := GenerateSignature(secret, timestamp, body)
86+
87+
req.Header.Set("X-Webhook-Signature", signature)
88+
req.Header.Set("X-Webhook-Timestamp", strconv.FormatInt(timestamp, 10))
89+
}
90+
91+
// ComputeSignature computes a webhook signature for a payload
92+
// This is useful for implementing custom webhook sending
93+
func ComputeSignature(secret string, payload []byte) (signature string, timestamp int64) {
94+
timestamp = time.Now().Unix()
95+
data := fmt.Sprintf("%d.%s", timestamp, string(payload))
96+
h := hmac.New(sha256.New, []byte(secret))
97+
h.Write([]byte(data))
98+
signature = "sha256=" + hex.EncodeToString(h.Sum(nil))
99+
return
100+
}
101+
102+
// Example verification code for webhook receivers:
103+
/*
104+
package main
105+
106+
import (
107+
"fmt"
108+
"net/http"
109+
110+
"github.com/radhi1991/aran-mcp-sentinel/internal/webhook"
111+
)
112+
113+
func main() {
114+
secret := "your-webhook-secret"
115+
client := webhook.NewClient(secret)
116+
117+
http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
118+
// Verify the webhook signature
119+
if err := client.VerifyRequest(r); err != nil {
120+
http.Error(w, "Invalid signature", http.StatusUnauthorized)
121+
return
122+
}
123+
124+
// Parse the payload
125+
payload, err := webhook.ParsePayload(r)
126+
if err != nil {
127+
http.Error(w, "Invalid payload", http.StatusBadRequest)
128+
return
129+
}
130+
131+
// Handle the event
132+
fmt.Printf("Received event: %s\n", payload.Event)
133+
fmt.Printf("Data: %+v\n", payload.Data)
134+
135+
w.WriteHeader(http.StatusOK)
136+
})
137+
138+
http.ListenAndServe(":8080", nil)
139+
}
140+
*/

0 commit comments

Comments
 (0)