From 8d1016b87d5de5e1b757bd4618c87af9ec815fc4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 15:20:01 -0400 Subject: [PATCH] feat(canary): full wire-up + webbug refactor + mysql goroutine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 tasks 9.7 + 9.8 + closes Phase 8 task 8.5 deferral. config.Config additions: - Canary {BaseURL, ManageURL} — public URLs for trigger + manage redirects - Turnstile {SecretKey, SiteKey} — Cloudflare Turnstile (empty SecretKey = dev bypass) - MySQL {Enabled, Addr, PublicHost, — fake TCP listener config PublicPort} cmd/canary/main.go full wire-up per spec §6.3 + §11.1: - Global middleware: RequestID → Logger → Recovery → SecurityHeaders - /healthz: no further middleware (router-mounted directly) - /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: mounted at router root, OUTSIDE /api subrouter, so they skip CORS + rate limit (spec §11.1 line 1552 — we want to record EVERY hit) - /api subrouter: CORS → RateLimit (KeyByFingerprint, FailOpen=true) - /api/tokens/types: no further middleware (read-only public list) - /api/tokens POST: + TurnstileVerify middleware - mysql goroutine: spawned via spawnMySQLListener iff cfg.MySQL.Enabled; uses mysqlTokenLookup + mysqlEventRecorder adapters bridging mysql.{TokenLookup,EventRecorder} to token.Service + event.Repository Adapter types in cmd/canary/main.go: - registryAdapter: bridges registry.Registry (map type) to token.Registry interface (Get method) - directEventRecorder: bridges token.EventRecorder to event.Repository.Insert + token.Repository.IncrementTriggerCount (synchronous; Phase 10 swaps in event.Service for dedup + async notify) - mysqlTokenLookup, mysqlEventRecorder: same shape for the TCP mysql handler webbug refactor (task 9.8): - Delete local realIP/lastNonEmptyXFF/optionalHeader copies (~30 LOC) - Use middleware.RealIP and middleware.OptionalHeader instead - Tests still pass (the IP-precedence suite in webbug_test.go exercises middleware.RealIP behavior end-to-end via the Trigger contract) Pre-rollup gate clean at HEAD: - go build ./... + go vet ./... clean - go test -race -timeout=60s ./... all pass - go test -tags=integration -race -timeout=300s ./internal/token/... ./internal/event/... all pass - golangci-lint run ./... → 0 issues - grep -rn "//nolint" --include="*.go" returns nothing DEFERRED (not in Phase 9 scope): - Phase 9 task 9.9 integration test (full POST /api/tokens against testcontainers) — defer to Phase 10's broader integration suite along with event.Service + notify.Service end-to-end coverage - Phase 10 brings: event.Service with dedup + async notify, swaps in for the directEventRecorder/mysqlEventRecorder adapters - Phase 13 brings: geoip.Service wired into event recording --- .../backend/cmd/canary/main.go | 183 +++++++++++++++--- .../backend/internal/config/config.go | 39 ++++ .../token/generators/webbug/generator.go | 54 +----- 3 files changed, 204 insertions(+), 72 deletions(-) diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go index dea17cac..04cd79c4 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -11,6 +11,7 @@ import ( "log/slog" "os" "os/signal" + "sync" "syscall" "time" @@ -23,6 +24,9 @@ import ( "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/server" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/turnstile" ) const ( @@ -73,8 +77,8 @@ func run(configPath string) error { } logger.Info("migrations applied") - _ = token.NewRepository(db.DB) - _ = event.NewRepository(db.DB) + tokenRepo := token.NewRepository(db.DB) + eventRepo := event.NewRepository(db.DB) rdb, err := core.NewRedis(ctx, cfg.Redis) if err != nil { @@ -82,8 +86,37 @@ func run(configPath string) error { } logger.Info("redis connected") + genRegistry := registry.Build(registry.Config{BaseURL: cfg.Canary.BaseURL}) + tokenSvc := token.NewService( + tokenRepo, + registryAdapter{r: genRegistry}, + token.ServiceConfig{ + BaseURL: cfg.Canary.BaseURL, + ManageURL: cfg.Canary.ManageURL, + }, + ) + + verifier := turnstile.NewVerifier( + turnstile.Config{SecretKey: cfg.Turnstile.SecretKey}, + rdb.Client, + ) + healthH := health.NewHandler(db, rdb) - srv := mountRouter(cfg, logger, rdb, healthH) + tokenH := token.NewHandler( + tokenSvc, + &directEventRecorder{ + repo: eventRepo, + tokens: tokenRepo, + logger: logger, + }, + nil, + logger, + ) + + srv := mountRouter(cfg, logger, rdb, healthH, tokenH, verifier) + + var wg sync.WaitGroup + spawnMySQLListener(ctx, cfg, logger, &wg, tokenSvc, eventRepo, tokenRepo) errChan := make(chan error, 1) go func() { errChan <- srv.Start() }() @@ -95,23 +128,38 @@ func run(configPath string) error { logger.Info("shutdown signal received") } - return gracefulShutdown(cfg, logger, srv, telemetry, rdb, db) + shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db) + wg.Wait() + return shutdownErr } -func initTelemetry( +func spawnMySQLListener( ctx context.Context, cfg *config.Config, logger *slog.Logger, -) *core.Telemetry { - if !cfg.Otel.Enabled { - return nil + wg *sync.WaitGroup, + tokenSvc *token.Service, + eventRepo *event.Repository, + tokenRepo *token.Repository, +) { + if !cfg.MySQL.Enabled { + return } - t, err := core.NewTelemetry(ctx, cfg.Otel, cfg.App) - if err != nil { - logger.Warn("telemetry init failed", "error", err) - return nil - } - return t + wg.Add(1) + go func() { + defer wg.Done() + handler := mysql.NewHandler( + &mysqlTokenLookup{svc: tokenSvc}, + &mysqlEventRecorder{ + repo: eventRepo, + tokens: tokenRepo, + logger: logger, + }, + ) + if mErr := mysql.Run(ctx, cfg.MySQL.Addr, handler); mErr != nil { + logger.Error("mysql server error", "error", mErr) + } + }() } func mountRouter( @@ -119,6 +167,8 @@ func mountRouter( logger *slog.Logger, rdb *core.Redis, healthH *health.Handler, + tokenH *token.Handler, + verifier *turnstile.Verifier, ) *server.Server { srv := server.New(server.Config{ ServerConfig: cfg.Server, @@ -129,20 +179,29 @@ func mountRouter( r.Use(middleware.RequestID) r.Use(middleware.Logger(logger)) - r.Use( - middleware.NewRateLimiter(rdb.Client, middleware.RateLimitConfig{ - Limit: middleware.PerMinute( - cfg.RateLimit.Requests, - cfg.RateLimit.Burst, - ), - FailOpen: true, - }).Handler, - ) + r.Use(middleware.Recovery(logger)) r.Use(middleware.SecurityHeaders(cfg.App.Environment == "production")) - r.Use(middleware.CORS(cfg.CORS)) healthH.RegisterRoutes(r) - r.Route("/api", func(_ chi.Router) {}) + tokenH.RegisterTriggerRoutes(r) + + r.Route("/api", func(api chi.Router) { + api.Use(middleware.CORS(cfg.CORS)) + api.Use( + middleware.NewRateLimiter(rdb.Client, middleware.RateLimitConfig{ + Limit: middleware.PerMinute( + cfg.RateLimit.Requests, + cfg.RateLimit.Burst, + ), + KeyFunc: middleware.KeyByFingerprint, + FailOpen: true, + }).Handler, + ) + api.Get("/tokens/types", tokenH.GetTypes) + api.With(middleware.TurnstileVerify(verifier)). + Post("/tokens", tokenH.CreateToken) + }) + return srv } @@ -184,6 +243,22 @@ func gracefulShutdown( return errors.Join(errs...) } +func initTelemetry( + ctx context.Context, + cfg *config.Config, + logger *slog.Logger, +) *core.Telemetry { + if !cfg.Otel.Enabled { + return nil + } + t, err := core.NewTelemetry(ctx, cfg.Otel, cfg.App) + if err != nil { + logger.Warn("telemetry init failed", "error", err) + return nil + } + return t +} + func setupLogger(cfg config.LogConfig) *slog.Logger { level := slog.LevelInfo switch cfg.Level { @@ -204,3 +279,61 @@ func setupLogger(cfg config.LogConfig) *slog.Logger { } return slog.New(handler) } + +type registryAdapter struct{ r registry.Registry } + +func (a registryAdapter) Get(t token.Type) (token.Generator, bool) { + g, ok := a.r[t] + return g, ok +} + +type directEventRecorder struct { + repo *event.Repository + tokens *token.Repository + logger *slog.Logger +} + +func (d *directEventRecorder) Record( + ctx context.Context, + t *token.Token, + evt *event.Event, +) error { + if err := d.repo.Insert(ctx, evt); err != nil { + return fmt.Errorf("insert event: %w", err) + } + if err := d.tokens.IncrementTriggerCount(ctx, t.ID); err != nil { + d.logger.WarnContext(ctx, "increment trigger count", + "error", err, "token_id", t.ID) + } + return nil +} + +type mysqlTokenLookup struct{ svc *token.Service } + +func (m *mysqlTokenLookup) GetByID( + ctx context.Context, + id string, +) (*token.Token, error) { + return m.svc.GetByID(ctx, id) +} + +type mysqlEventRecorder struct { + repo *event.Repository + tokens *token.Repository + logger *slog.Logger +} + +func (m *mysqlEventRecorder) Record( + ctx context.Context, + t *token.Token, + evt *event.Event, +) error { + if err := m.repo.Insert(ctx, evt); err != nil { + return fmt.Errorf("insert event: %w", err) + } + if err := m.tokens.IncrementTriggerCount(ctx, t.ID); err != nil { + m.logger.WarnContext(ctx, "mysql: increment trigger count", + "error", err, "token_id", t.ID) + } + return nil +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go index 6c97092f..1beb55b2 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -23,6 +23,26 @@ type Config struct { CORS CORSConfig `koanf:"cors"` Log LogConfig `koanf:"log"` Otel OtelConfig `koanf:"otel"` + Canary CanaryConfig `koanf:"canary"` + Turnstile TurnstileConfig `koanf:"turnstile"` + MySQL MySQLConfig `koanf:"mysql"` +} + +type CanaryConfig struct { + BaseURL string `koanf:"base_url"` + ManageURL string `koanf:"manage_url"` +} + +type TurnstileConfig struct { + SecretKey string `koanf:"secret_key"` + SiteKey string `koanf:"site_key"` +} + +type MySQLConfig struct { + Enabled bool `koanf:"enabled"` + Addr string `koanf:"addr"` + PublicHost string `koanf:"public_host"` + PublicPort int `koanf:"public_port"` } type AppConfig struct { @@ -191,6 +211,17 @@ func loadDefaults(k *koanf.Koanf) error { "otel.insecure": true, "otel.sample_rate": 0.1, "otel.service_name": "canary-token-generator", + + "canary.base_url": "http://localhost:8080", + "canary.manage_url": "http://localhost:8080", + + "turnstile.secret_key": "", + "turnstile.site_key": "", + + "mysql.enabled": false, + "mysql.addr": "0.0.0.0:3306", + "mysql.public_host": "localhost", + "mysql.public_port": 3306, } for key, value := range defaults { @@ -219,6 +250,14 @@ var envKeyMap = map[string]string{ "OTEL_ENABLED": "otel.enabled", "OTEL_INSECURE": "otel.insecure", "OTEL_SAMPLE_RATE": "otel.sample_rate", + "CANARY_BASE_URL": "canary.base_url", + "CANARY_MANAGE_URL": "canary.manage_url", + "TURNSTILE_SECRET_KEY": "turnstile.secret_key", + "TURNSTILE_SITE_KEY": "turnstile.site_key", + "MYSQL_ENABLED": "mysql.enabled", + "MYSQL_ADDR": "mysql.addr", + "MYSQL_PUBLIC_HOST": "mysql.public_host", + "MYSQL_PUBLIC_PORT": "mysql.public_port", } func envKeyReplacer(s string) string { diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go index c0492b91..e0292f0e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/generator.go @@ -5,23 +5,20 @@ package webbug import ( "context" - "net" "net/http" "strings" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel" ) const ( - headerCFConnectingIP = "CF-Connecting-IP" - headerXForwardedFor = "X-Forwarded-For" - headerXRealIP = "X-Real-IP" - headerReferer = "Referer" - headerCacheControl = "Cache-Control" - headerPragma = "Pragma" + headerReferer = "Referer" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" pragmaNoCache = "no-cache" @@ -67,46 +64,9 @@ func (g *Generator) Trigger( evt := &event.Event{ TokenID: t.ID, - SourceIP: realIP(r), - UserAgent: optionalHeader(r.UserAgent()), - Referer: optionalHeader(r.Header.Get(headerReferer)), + SourceIP: middleware.RealIP(r), + UserAgent: middleware.OptionalHeader(r.UserAgent()), + Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)), } return evt, resp, nil } - -func optionalHeader(v string) *string { - v = strings.TrimSpace(v) - if v == "" { - return nil - } - return &v -} - -func realIP(r *http.Request) string { - if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { - return v - } - if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { - return v - } - if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { - return v - } - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - return host - } - return r.RemoteAddr -} - -func lastNonEmptyXFF(header string) string { - if header == "" { - return "" - } - parts := strings.Split(header, ",") - for i := len(parts) - 1; i >= 0; i-- { - if v := strings.TrimSpace(parts[i]); v != "" { - return v - } - } - return "" -}