Update Go module path and all import references to reflect the migration from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
4.4 KiB
Go
176 lines
4.4 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.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
|
|
}
|
|
return slices.Contains(wh.Events, eventType)
|
|
}
|
|
|
|
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)
|
|
}
|