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
345 lines
8.7 KiB
Go
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,
|
|
})
|
|
}
|