package notify import ( "bytes" "context" "encoding/json" "log/slog" "net/http" "sync" "time" "git.t-juice.club/torjus/oubliette/internal/config" ) // Event types. const ( EventHumanDetected = "human_detected" EventSessionStarted = "session_started" ) // SessionInfo holds session data included in webhook payloads. type SessionInfo struct { ID string `json:"id"` IP string `json:"ip"` Username string `json:"username"` ShellName string `json:"shell_name"` HumanScore float64 `json:"human_score"` ConnectedAt string `json:"connected_at"` } // webhookPayload is the JSON body sent to webhooks. type webhookPayload struct { Event string `json:"event"` Timestamp string `json:"timestamp"` Session SessionInfo `json:"session"` } // Notifier sends webhook notifications for honeypot events. type Notifier struct { webhooks []config.WebhookNotifyConfig logger *slog.Logger client *http.Client mu sync.Mutex sent map[string]struct{} // dedup key: "sessionID:eventType" } // NewNotifier creates a Notifier with the given webhook configurations. func NewNotifier(webhooks []config.WebhookNotifyConfig, logger *slog.Logger) *Notifier { return &Notifier{ webhooks: webhooks, logger: logger, client: &http.Client{Timeout: 10 * time.Second}, sent: make(map[string]struct{}), } } // Notify sends a notification for the given event type and session. // Deduplicates by (sessionID, eventType) — each combination is sent at most once. func (n *Notifier) Notify(ctx context.Context, eventType string, session SessionInfo) { dedupKey := session.ID + ":" + eventType n.mu.Lock() if _, ok := n.sent[dedupKey]; ok { n.mu.Unlock() return } n.sent[dedupKey] = struct{}{} n.mu.Unlock() payload := webhookPayload{ Event: eventType, Timestamp: time.Now().UTC().Format(time.RFC3339), Session: session, } for _, wh := range n.webhooks { if !n.shouldSend(wh, eventType) { continue } go n.send(ctx, wh, payload) } } // CleanupSession removes dedup state for a session. func (n *Notifier) CleanupSession(sessionID string) { n.mu.Lock() defer n.mu.Unlock() for key := range n.sent { if len(key) > len(sessionID) && key[:len(sessionID)+1] == sessionID+":" { delete(n.sent, key) } } } // shouldSend returns true if the webhook is configured to receive this event type. func (n *Notifier) shouldSend(wh config.WebhookNotifyConfig, eventType string) bool { if len(wh.Events) == 0 { return true // empty = all events } for _, ev := range wh.Events { if ev == eventType { return true } } return false } func (n *Notifier) send(ctx context.Context, wh config.WebhookNotifyConfig, payload webhookPayload) { body, err := json.Marshal(payload) if err != nil { n.logger.Error("failed to marshal webhook payload", "err", err) return } req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader(body)) if err != nil { n.logger.Error("failed to create webhook request", "err", err, "url", wh.URL) return } req.Header.Set("Content-Type", "application/json") for k, v := range wh.Headers { req.Header.Set(k, v) } resp, err := n.client.Do(req) if err != nil { n.logger.Error("webhook request failed", "err", err, "url", wh.URL) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { n.logger.Warn("webhook returned error status", "url", wh.URL, "status", resp.StatusCode, "event", payload.Event, ) return } n.logger.Debug("webhook sent", "url", wh.URL, "event", payload.Event, "session_id", payload.Session.ID, ) } // FormatConnectedAt formats a time for use in SessionInfo. func FormatConnectedAt(t time.Time) string { return t.UTC().Format(time.RFC3339) } // NoopNotifier is a no-op notifier used when no webhooks are configured. type NoopNotifier struct{} func (NoopNotifier) Notify(context.Context, string, SessionInfo) {} func (NoopNotifier) CleanupSession(string) {} // Sender is the interface for sending notifications. type Sender interface { Notify(ctx context.Context, eventType string, session SessionInfo) CleanupSession(sessionID string) } var ( _ Sender = (*Notifier)(nil) _ Sender = NoopNotifier{} ) // NewSender creates a Sender from configuration. Returns a NoopNotifier // if no webhooks are configured. func NewSender(webhooks []config.WebhookNotifyConfig, logger *slog.Logger) Sender { if len(webhooks) == 0 { return NoopNotifier{} } return NewNotifier(webhooks, logger) }