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, }) }