Cybersecurity-Projects/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go

200 lines
4.2 KiB
Go

// ©AngelaMos | 2026
// service.go
package event
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
const (
dedupKeyPrefix = "dedup:trigger:"
dedupActivePrefix = "dedup:active:"
defaultDedupTTL = 15 * time.Minute
)
type Service struct {
repo Store
tokens TokenIncrementer
rdb *redis.Client
notifier Notifier
geo geoip.Lookuper
dedupTTL time.Duration
logger *slog.Logger
}
type ServiceConfig struct {
DedupTTL time.Duration
Logger *slog.Logger
GeoIP geoip.Lookuper
}
func NewService(
repo Store,
tokens TokenIncrementer,
rdb *redis.Client,
notifier Notifier,
cfg ServiceConfig,
) *Service {
if cfg.DedupTTL <= 0 {
cfg.DedupTTL = defaultDedupTTL
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
return &Service{
repo: repo,
tokens: tokens,
rdb: rdb,
notifier: notifier,
geo: cfg.GeoIP,
dedupTTL: cfg.DedupTTL,
logger: cfg.Logger,
}
}
func DedupKey(tokenID, sourceIP string) string {
return dedupKeyPrefix + tokenID + ":" + sourceIP
}
func (s *Service) Record(
ctx context.Context,
info NotifyInfo,
evt *Event,
) error {
s.enrichGeo(evt)
if err := s.repo.Insert(ctx, evt); err != nil {
return fmt.Errorf("insert event: %w", err)
}
if s.tokens != nil {
if err := s.tokens.IncrementTriggerCount(
ctx,
info.TokenID,
); err != nil {
s.logger.WarnContext(ctx, "increment trigger count",
"error", err, "token_id", info.TokenID)
}
}
first := s.dedupGate(ctx, info.TokenID, evt.SourceIP)
if !first {
if err := s.repo.UpdateNotifyStatus(
ctx, evt.ID, NotifyDeduped, nil,
); err != nil {
s.logger.WarnContext(ctx, "update notify status deduped",
"error", err, "event_id", evt.ID)
}
return nil
}
if s.notifier != nil {
s.notifier.Notify(info, evt)
}
return nil
}
func (s *Service) enrichGeo(evt *Event) {
if s.geo == nil || evt == nil || evt.SourceIP == "" {
return
}
evt.AttachGeoIP(s.geo.Lookup(evt.SourceIP))
}
func (s *Service) dedupGate(
ctx context.Context,
tokenID, sourceIP string,
) bool {
if s.rdb == nil {
return true
}
key := DedupKey(tokenID, sourceIP)
set, err := s.rdb.SetNX(ctx, key, 1, s.dedupTTL).Result()
if err != nil {
s.logger.WarnContext(ctx, "dedup setnx failed (fail-open)",
"error", err, "key", key)
return true
}
if set {
return true
}
if _, iErr := s.rdb.Incr(ctx, key).Result(); iErr != nil {
s.logger.WarnContext(ctx, "dedup incr failed",
"error", iErr, "key", key)
}
trackKey := dedupActivePrefix + tokenID
if _, sErr := s.rdb.SAdd(ctx, trackKey, sourceIP).Result(); sErr != nil {
s.logger.WarnContext(ctx, "dedup track add",
"error", sErr, "key", trackKey)
}
if _, eErr := s.rdb.Expire(
ctx, trackKey, s.dedupTTL,
).Result(); eErr != nil {
s.logger.WarnContext(ctx, "dedup track expire",
"error", eErr, "key", trackKey)
}
return false
}
func (s *Service) CountActiveDedup(
ctx context.Context,
tokenID string,
) (int64, error) {
if s.rdb == nil {
return 0, nil
}
n, err := s.rdb.SCard(ctx, dedupActivePrefix+tokenID).Result()
if errors.Is(err, redis.Nil) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("dedup count: %w", err)
}
return n, nil
}
func (s *Service) RunRetentionLoop(
ctx context.Context,
interval time.Duration,
perTokenLimit int,
) {
if interval <= 0 || perTokenLimit <= 0 {
s.logger.WarnContext(ctx, "retention loop disabled (invalid config)",
"interval", interval, "limit", perTokenLimit)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.logger.InfoContext(ctx, "retention loop started",
"interval", interval, "per_token_limit", perTokenLimit)
for {
select {
case <-ctx.Done():
s.logger.InfoContext(ctx, "retention loop stopped")
return
case <-ticker.C:
n, err := s.repo.PruneToLimit(ctx, perTokenLimit)
if err != nil {
s.logger.WarnContext(ctx, "retention loop: prune failed",
"error", err, "per_token_limit", perTokenLimit)
continue
}
if n > 0 {
s.logger.InfoContext(ctx, "retention loop: pruned events",
"deleted", n, "per_token_limit", perTokenLimit)
}
}
}
}