feat(canary): full wire-up + webbug refactor + mysql goroutine
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
This commit is contained in:
parent
0a456f97ae
commit
8d1016b87d
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue