caddy-sip-guardian/webhooks.go
Ryan Malloy c73fa9d3d1 Add extension enumeration detection and comprehensive SIP protection
Major features:
- Extension enumeration detection with 3 detection algorithms:
  - Max unique extensions threshold (default: 20 in 5 min)
  - Sequential pattern detection (e.g., 100,101,102...)
  - Rapid-fire detection (many extensions in short window)
- Prometheus metrics for all SIP Guardian operations
- SQLite persistent storage for bans and attack history
- Webhook notifications for ban/unban/suspicious events
- GeoIP-based country blocking with continent shortcuts
- Per-method rate limiting with token bucket algorithm

Bug fixes:
- Fix whitelist count always reporting zero in stats
- Fix whitelisted connections metric never incrementing
- Fix Caddyfile config not being applied to shared guardian

New files:
- enumeration.go: Extension enumeration detector
- enumeration_test.go: 14 comprehensive unit tests
- metrics.go: Prometheus metrics handler
- storage.go: SQLite persistence layer
- webhooks.go: Webhook notification system
- geoip.go: MaxMind GeoIP integration
- ratelimit.go: Per-method rate limiting

Testing:
- sandbox/ contains complete Docker Compose test environment
- All 14 enumeration tests pass
2025-12-07 15:22:28 -07:00

345 lines
8.7 KiB
Go

package sipguardian
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"go.uber.org/zap"
)
// WebhookEvent represents an event to be sent via webhook
type WebhookEvent struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data interface{} `json:"data"`
}
// BanEventData contains data for ban/unban events
type BanEventData struct {
IP string `json:"ip"`
Reason string `json:"reason,omitempty"`
BannedAt time.Time `json:"banned_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
HitCount int `json:"hit_count,omitempty"`
Duration string `json:"duration,omitempty"`
}
// SuspiciousEventData contains data for suspicious activity events
type SuspiciousEventData struct {
IP string `json:"ip"`
Pattern string `json:"pattern"`
Sample string `json:"sample,omitempty"`
FailureCount int `json:"failure_count"`
}
// EnumerationEventData contains data for enumeration detection events
type EnumerationEventData struct {
IP string `json:"ip"`
Reason string `json:"reason"`
UniqueCount int `json:"unique_count"`
Extensions []string `json:"extensions,omitempty"`
SeqStart int `json:"seq_start,omitempty"`
SeqEnd int `json:"seq_end,omitempty"`
}
// WebhookConfig holds webhook configuration
type WebhookConfig struct {
// URL to send webhook events to
URL string `json:"url"`
// Secret for HMAC signature (optional)
Secret string `json:"secret,omitempty"`
// Events to subscribe to (default: all)
// Options: "ban", "unban", "suspicious", "failure"
Events []string `json:"events,omitempty"`
// Timeout for webhook requests
Timeout time.Duration `json:"timeout,omitempty"`
// RetryCount for failed webhook deliveries
RetryCount int `json:"retry_count,omitempty"`
// Headers to include in webhook requests
Headers map[string]string `json:"headers,omitempty"`
}
// WebhookManager handles webhook dispatching
type WebhookManager struct {
configs []WebhookConfig
client *http.Client
logger *zap.Logger
mu sync.RWMutex
// Channel for async event dispatching
eventChan chan WebhookEvent
done chan struct{}
}
// Global webhook manager instance
var (
webhookManager *WebhookManager
webhookMu sync.Mutex
)
// GetWebhookManager returns the global webhook manager, creating it if necessary
func GetWebhookManager(logger *zap.Logger) *WebhookManager {
webhookMu.Lock()
defer webhookMu.Unlock()
if webhookManager == nil {
webhookManager = &WebhookManager{
configs: []WebhookConfig{},
client: &http.Client{Timeout: 10 * time.Second},
logger: logger,
eventChan: make(chan WebhookEvent, 100),
done: make(chan struct{}),
}
go webhookManager.dispatcher()
}
return webhookManager
}
// AddWebhook registers a new webhook endpoint
func (wm *WebhookManager) AddWebhook(config WebhookConfig) {
wm.mu.Lock()
defer wm.mu.Unlock()
// Set defaults
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.RetryCount == 0 {
config.RetryCount = 3
}
if len(config.Events) == 0 {
config.Events = []string{"ban", "unban", "suspicious", "failure", "enumeration"}
}
wm.configs = append(wm.configs, config)
wm.logger.Info("Webhook registered",
zap.String("url", config.URL),
zap.Strings("events", config.Events),
)
}
// ClearWebhooks removes all registered webhooks
func (wm *WebhookManager) ClearWebhooks() {
wm.mu.Lock()
defer wm.mu.Unlock()
wm.configs = []WebhookConfig{}
}
// Emit sends an event to all subscribed webhooks
func (wm *WebhookManager) Emit(eventType string, data interface{}) {
event := WebhookEvent{
Type: eventType,
Timestamp: time.Now().UTC(),
Data: data,
}
select {
case wm.eventChan <- event:
// Event queued
default:
wm.logger.Warn("Webhook event queue full, dropping event",
zap.String("type", eventType),
)
}
}
// dispatcher processes events from the channel
func (wm *WebhookManager) dispatcher() {
for {
select {
case <-wm.done:
return
case event := <-wm.eventChan:
wm.dispatch(event)
}
}
}
// dispatch sends an event to all matching webhooks
func (wm *WebhookManager) dispatch(event WebhookEvent) {
wm.mu.RLock()
configs := make([]WebhookConfig, len(wm.configs))
copy(configs, wm.configs)
wm.mu.RUnlock()
for _, config := range configs {
if wm.shouldSend(config, event.Type) {
go wm.send(config, event)
}
}
}
// shouldSend checks if an event type matches the webhook's subscriptions
func (wm *WebhookManager) shouldSend(config WebhookConfig, eventType string) bool {
for _, e := range config.Events {
if e == eventType || e == "all" {
return true
}
}
return false
}
// send delivers a webhook event with retries
func (wm *WebhookManager) send(config WebhookConfig, event WebhookEvent) {
payload, err := json.Marshal(event)
if err != nil {
wm.logger.Error("Failed to marshal webhook event",
zap.Error(err),
zap.String("type", event.Type),
)
return
}
var lastErr error
for attempt := 0; attempt <= config.RetryCount; attempt++ {
if attempt > 0 {
// Exponential backoff
time.Sleep(time.Duration(attempt*attempt) * time.Second)
}
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
req, err := http.NewRequestWithContext(ctx, "POST", config.URL, bytes.NewReader(payload))
if err != nil {
cancel()
lastErr = err
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "SIP-Guardian-Webhook/1.0")
req.Header.Set("X-SIP-Guardian-Event", event.Type)
// Add custom headers
for k, v := range config.Headers {
req.Header.Set(k, v)
}
// Add HMAC signature if secret is configured
if config.Secret != "" {
signature := computeHMAC(payload, config.Secret)
req.Header.Set("X-SIP-Guardian-Signature", signature)
}
resp, err := wm.client.Do(req)
cancel()
if err != nil {
lastErr = err
wm.logger.Debug("Webhook delivery failed, retrying",
zap.String("url", config.URL),
zap.Int("attempt", attempt+1),
zap.Error(err),
)
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
wm.logger.Debug("Webhook delivered successfully",
zap.String("url", config.URL),
zap.String("type", event.Type),
zap.Int("status", resp.StatusCode),
)
return
}
lastErr = fmt.Errorf("unexpected status code: %d", resp.StatusCode)
wm.logger.Debug("Webhook returned non-success status",
zap.String("url", config.URL),
zap.Int("status", resp.StatusCode),
zap.Int("attempt", attempt+1),
)
}
wm.logger.Error("Webhook delivery failed after retries",
zap.String("url", config.URL),
zap.String("type", event.Type),
zap.Error(lastErr),
)
}
// Stop gracefully shuts down the webhook manager
func (wm *WebhookManager) Stop() {
close(wm.done)
}
// computeHMAC generates an HMAC-SHA256 signature for webhook verification
func computeHMAC(payload []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// Helper functions for emitting specific events
// EmitBanEvent sends a ban notification
func EmitBanEvent(logger *zap.Logger, entry *BanEntry) {
wm := GetWebhookManager(logger)
wm.Emit("ban", BanEventData{
IP: entry.IP,
Reason: entry.Reason,
BannedAt: entry.BannedAt,
ExpiresAt: entry.ExpiresAt,
HitCount: entry.HitCount,
Duration: entry.ExpiresAt.Sub(entry.BannedAt).String(),
})
}
// EmitUnbanEvent sends an unban notification
func EmitUnbanEvent(logger *zap.Logger, ip string, reason string) {
wm := GetWebhookManager(logger)
wm.Emit("unban", BanEventData{
IP: ip,
Reason: reason,
})
}
// EmitSuspiciousEvent sends a suspicious activity notification
func EmitSuspiciousEvent(logger *zap.Logger, ip, pattern, sample string, failureCount int) {
wm := GetWebhookManager(logger)
wm.Emit("suspicious", SuspiciousEventData{
IP: ip,
Pattern: pattern,
Sample: sample,
FailureCount: failureCount,
})
}
// EmitFailureEvent sends a failure notification
func EmitFailureEvent(logger *zap.Logger, ip, reason string, count int) {
wm := GetWebhookManager(logger)
wm.Emit("failure", map[string]interface{}{
"ip": ip,
"reason": reason,
"count": count,
})
}
// EmitEnumerationEvent sends an enumeration detection notification
func EmitEnumerationEvent(logger *zap.Logger, ip string, result EnumerationResult) {
wm := GetWebhookManager(logger)
wm.Emit("enumeration", EnumerationEventData{
IP: ip,
Reason: result.Reason,
UniqueCount: result.UniqueCount,
Extensions: result.Extensions,
SeqStart: result.SeqStart,
SeqEnd: result.SeqEnd,
})
}