diff --git a/PROJECTS/beginner/canary-token-generator/backend/.air.toml b/PROJECTS/beginner/canary-token-generator/backend/.air.toml new file mode 100644 index 00000000..1289f607 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.air.toml @@ -0,0 +1,29 @@ +# AngelaMos | 2026 +# .air.toml - Hot reload configuration + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/canary" +bin = "tmp/main" +full_bin = "./tmp/main" +include_ext = ["go", "yaml", "yml"] +exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"] +exclude_regex = ["_test\\.go"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "cyan" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/PROJECTS/beginner/canary-token-generator/backend/.env.example b/PROJECTS/beginner/canary-token-generator/backend/.env.example new file mode 100644 index 00000000..f0cd29e1 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.env.example @@ -0,0 +1,27 @@ +# AngelaMos | 2026 +# .env.example + +# Environment: development, staging, production +ENVIRONMENT=development + +# Server +HOST=0.0.0.0 +PORT=8080 + +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m + +# OpenTelemetry +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=canary-token-generator + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text diff --git a/PROJECTS/beginner/canary-token-generator/backend/.gitignore b/PROJECTS/beginner/canary-token-generator/backend/.gitignore new file mode 100644 index 00000000..3688c87b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.gitignore @@ -0,0 +1,44 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test +*.test +coverage.out +coverage.html + +# Build +tmp/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Keys (sensitive) +keys/*.pem +keys/*.key + +# Vendor (if using) +vendor/ + +# Debug +__debug_bin* diff --git a/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml new file mode 100644 index 00000000..0d37a865 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/.golangci.yml @@ -0,0 +1,113 @@ +# AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + settings: + errcheck: + check-type-assertions: true + check-blank: true + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 + + sloglint: + no-mixed-args: true + kv-only: true + context: all + +issues: + max-same-issues: 50 + exclude-dirs: + - vendor + - testdata + exclude-rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + +formatters: + enable: + - gci # Groups imports + - gofumpt # Whitespace + - golines # Vertical wrap + settings: + golines: + max-len: 80 + reformat-tags: true + goimports: + local-prefixes: + - github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator + gci: + sections: + - standard + - default + - prefix(github.com/CarterPerez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/beginner/canary-token-generator/backend/Dockerfile b/PROJECTS/beginner/canary-token-generator/backend/Dockerfile new file mode 100644 index 00000000..d6f02050 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/Dockerfile @@ -0,0 +1,36 @@ +# AngelaMos | 2026 +# Dockerfile - Multi-stage distroless build + +# Build stage +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +# Copy dependency files first for layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/api + +# Production stage - Distroless +FROM gcr.io/distroless/static-debian12 + +# Copy binary from builder +COPY --from=builder /app /app + +# Copy migrations (embedded, but useful for debugging) +COPY --from=builder /build/migrations /migrations + +# Copy keys if they exist (for JWT) +COPY --from=builder /build/keys /keys + +# Run as non-root user +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/app"] diff --git a/PROJECTS/beginner/canary-token-generator/backend/Justfile b/PROJECTS/beginner/canary-token-generator/backend/Justfile new file mode 100644 index 00000000..118968ee --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/Justfile @@ -0,0 +1,97 @@ +# AngelaMos | 2026 +# Justfile + +set dotenv-load := true + +# Default recipe +default: + @just --list + +# Development +# ----------- + +# Run the API server with hot reload (requires air) +dev: + air -c .air.toml + +# Run the canary server without hot reload +run: + go run ./cmd/canary + +# Build the binary +build: + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bin/canary ./cmd/canary + +# Run tests +test: + go test -v -race -coverprofile=coverage.out ./... + +# Run tests with coverage report +test-coverage: test + go tool cover -html=coverage.out -o coverage.html + +# Run linter +lint: + golangci-lint run --timeout=5m + +# Format code +fmt: + gofumpt -w . + goimports -w . + +# Tidy dependencies +tidy: + go mod tidy + +# Database +# -------- + +# Run database migrations +migrate-up: + goose -dir migrations postgres "${DATABASE_URL}" up + +# Rollback last migration +migrate-down: + goose -dir migrations postgres "${DATABASE_URL}" down + +# Check migration status +migrate-status: + goose -dir migrations postgres "${DATABASE_URL}" status + +# Create new migration +migrate-create name: + goose -dir migrations create {{name}} sql + +# Docker +# ------ + +# Start development containers +up: + docker compose -f dev.compose.yml up -d + +# Stop development containers +down: + docker compose -f dev.compose.yml down + +# View container logs +logs: + docker compose -f dev.compose.yml logs -f + +# Rebuild and restart containers +rebuild: + docker compose -f dev.compose.yml up -d --build + +# Build production image +docker-build: + docker build -t canary-token-generator:latest . + +# Clean +# ----- + +# Remove build artifacts +clean: + rm -rf bin/ coverage.out coverage.html tmp/ + +# Full clean including containers +clean-all: clean + docker compose -f dev.compose.yml down -v diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go new file mode 100644 index 00000000..061a9551 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -0,0 +1,148 @@ +// ©AngelaMos | 2026 +// main.go + +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/server" +) + +const drainDelay = 5 * time.Second + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + + if err := run(*configPath); err != nil { + slog.Error("application error", "error", err) + os.Exit(1) + } +} + +func run(configPath string) error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + logger := setupLogger(cfg.Log) + slog.SetDefault(logger) + logger.Info("starting canary-token-generator", + "version", cfg.App.Version, + "environment", cfg.App.Environment, + ) + + var telemetry *core.Telemetry + if cfg.Otel.Enabled { + if t, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App); telErr != nil { + logger.Warn("telemetry init failed", "error", telErr) + } else { + telemetry = t + } + } + + db, err := core.NewDatabase(ctx, cfg.Database) + if err != nil { + return err + } + logger.Info("database connected") + + rdb, err := core.NewRedis(ctx, cfg.Redis) + if err != nil { + return err + } + logger.Info("redis connected") + + healthH := health.NewHandler(db, rdb) + + srv := server.New(server.Config{ + ServerConfig: cfg.Server, + HealthHandler: healthH, + Logger: logger, + }) + r := srv.Router() + + 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.SecurityHeaders(cfg.App.Environment == "production")) + r.Use(middleware.CORS(cfg.CORS)) + + healthH.RegisterRoutes(r) + + r.Route("/api", func(_ chi.Router) { + }) + + errChan := make(chan error, 1) + go func() { errChan <- srv.Start() }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + logger.Info("shutdown signal received") + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), + cfg.Server.ShutdownTimeout+drainDelay+5*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil { + logger.Error("server shutdown error", "error", err) + } + if telemetry != nil { + _ = telemetry.Shutdown(shutdownCtx) + } + if err := rdb.Close(); err != nil { + logger.Error("redis close error", "error", err) + } + if err := db.Close(); err != nil { + logger.Error("database close error", "error", err) + } + + logger.Info("application stopped") + return nil +} + +func setupLogger(cfg config.LogConfig) *slog.Logger { + level := slog.LevelInfo + switch cfg.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + opts := &slog.HandlerOptions{Level: level} + + var handler slog.Handler + if cfg.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + return slog.New(handler) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/compose.yml b/PROJECTS/beginner/canary-token-generator/backend/compose.yml new file mode 100644 index 00000000..66d66a22 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/compose.yml @@ -0,0 +1,59 @@ +# AngelaMos | 2026 +# compose.yml - Production compose + +services: + api: + image: go-backend:latest + ports: + - "8085:8080" + environment: + - ENVIRONMENT=production + - DATABASE_URL=postgres://postgres:postgres@postgres:5432/app?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + redisdata: diff --git a/PROJECTS/beginner/canary-token-generator/backend/config.yaml b/PROJECTS/beginner/canary-token-generator/backend/config.yaml new file mode 100644 index 00000000..959fc851 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/config.yaml @@ -0,0 +1,48 @@ +# AngelaMos | 2026 +# config.yaml - Default configuration + +app: + name: "Canary Token Generator" + version: "1.0.0" + +server: + host: "0.0.0.0" + port: 8080 + read_timeout: 30s + write_timeout: 30s + idle_timeout: 120s + shutdown_timeout: 15s + +database: + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + conn_max_idle_time: 30m + +redis: + pool_size: 10 + min_idle_conns: 5 + +rate_limit: + requests: 100 + window: 1m + burst: 20 + +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:3420" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Request-ID" + allow_credentials: true + max_age: 300 diff --git a/PROJECTS/beginner/canary-token-generator/backend/dev.compose.yml b/PROJECTS/beginner/canary-token-generator/backend/dev.compose.yml new file mode 100644 index 00000000..26bfa456 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/dev.compose.yml @@ -0,0 +1,45 @@ +# AngelaMos | 2026 +# dev.compose.yml - Development compose + +services: + postgres: + image: postgres:18-alpine + ports: + - "5447:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + volumes: + - pgdata_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6022:6379" + command: redis-server --appendonly yes + volumes: + - redisdata_dev:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + jaeger: + image: jaegertracing/all-in-one:1.54 + ports: + - "16686:16686" + - "4317:4317" + - "4318:4318" + environment: + COLLECTOR_OTLP_ENABLED: "true" + +volumes: + pgdata_dev: + redisdata_dev: diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.mod b/PROJECTS/beginner/canary-token-generator/backend/go.mod new file mode 100644 index 00000000..3a90c8d6 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/go.mod @@ -0,0 +1,57 @@ +module github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-playground/validator/v10 v10.23.0 + github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.1.2 + github.com/redis/go-redis/v9 v9.7.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + golang.org/x/crypto v0.43.0 + golang.org/x/time v0.14.0 + google.golang.org/grpc v1.68.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/protobuf v1.35.2 // indirect +) diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.sum b/PROJECTS/beginner/canary-token-generator/backend/go.sum new file mode 100644 index 00000000..0fa4bd52 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/go.sum @@ -0,0 +1,139 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go new file mode 100644 index 00000000..61fa4f23 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go @@ -0,0 +1,208 @@ +// AngelaMos | 2026 +// handler.go + +package admin + +import ( + "context" + "database/sql" + "net/http" + "runtime" + + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core" +) + +type AuthService interface { + InvalidateAllSessions(ctx context.Context) error +} + +type Handler struct { + dbStats func() sql.DBStats + redisStats func() *redis.PoolStats + redisPing func(ctx context.Context) error + dbPing func(ctx context.Context) error + authSvc AuthService +} + +type HandlerConfig struct { + DBStats func() sql.DBStats + RedisStats func() *redis.PoolStats + RedisPing func(ctx context.Context) error + DBPing func(ctx context.Context) error + AuthSvc AuthService +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + dbStats: cfg.DBStats, + redisStats: cfg.RedisStats, + redisPing: cfg.RedisPing, + dbPing: cfg.DBPing, + authSvc: cfg.AuthSvc, + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/stats", h.GetSystemStats) + r.Get("/stats/db", h.GetDatabaseStats) + r.Get("/stats/redis", h.GetRedisStats) + r.Get("/stats/runtime", h.GetRuntimeStats) + }) +} + +func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := true + if h.dbPing != nil { + if err := h.dbPing(ctx); err != nil { + dbHealthy = false + } + } + + redisHealthy := true + if h.redisPing != nil { + if err := h.redisPing(ctx); err != nil { + redisHealthy = false + } + } + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := SystemStatsResponse{ + Database: DatabaseStatus{ + Healthy: dbHealthy, + Stats: h.getDBStats(), + }, + Redis: RedisStatus{ + Healthy: redisHealthy, + Stats: h.getRedisStats(), + }, + Runtime: RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + }, + } + + core.OK(w, response) +} + +func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getDBStats()) +} + +func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getRedisStats()) +} + +func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + } + + core.OK(w, response) +} + +func (h *Handler) getDBStats() *DBPoolStats { + if h.dbStats == nil { + return nil + } + + stats := h.dbStats() + return &DBPoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration.String(), + MaxIdleClosed: stats.MaxIdleClosed, + MaxIdleTimeClosed: stats.MaxIdleTimeClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (h *Handler) getRedisStats() *RedisPoolStats { + if h.redisStats == nil { + return nil + } + + stats := h.redisStats() + return &RedisPoolStats{ + Hits: stats.Hits, + Misses: stats.Misses, + Timeouts: stats.Timeouts, + TotalConns: stats.TotalConns, + IdleConns: stats.IdleConns, + StaleConns: stats.StaleConns, + } +} + +type SystemStatsResponse struct { + Database DatabaseStatus `json:"database"` + Redis RedisStatus `json:"redis"` + Runtime RuntimeStats `json:"runtime"` +} + +type DatabaseStatus struct { + Healthy bool `json:"healthy"` + Stats *DBPoolStats `json:"stats,omitempty"` +} + +type RedisStatus struct { + Healthy bool `json:"healthy"` + Stats *RedisPoolStats `json:"stats,omitempty"` +} + +type DBPoolStats struct { + MaxOpenConnections int `json:"max_open_connections"` + OpenConnections int `json:"open_connections"` + InUse int `json:"in_use"` + Idle int `json:"idle"` + WaitCount int64 `json:"wait_count"` + WaitDuration string `json:"wait_duration"` + MaxIdleClosed int64 `json:"max_idle_closed"` + MaxIdleTimeClosed int64 `json:"max_idle_time_closed"` + MaxLifetimeClosed int64 `json:"max_lifetime_closed"` +} + +type RedisPoolStats struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConns uint32 `json:"total_conns"` + IdleConns uint32 `json:"idle_conns"` + StaleConns uint32 `json:"stale_conns"` +} + +type RuntimeStats struct { + GoVersion string `json:"go_version"` + NumGoroutine int `json:"num_goroutine"` + NumCPU int `json:"num_cpu"` + MemAlloc uint64 `json:"mem_alloc_bytes"` + MemSys uint64 `json:"mem_sys_bytes"` + NumGC uint32 `json:"num_gc"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go new file mode 100644 index 00000000..955afe68 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -0,0 +1,271 @@ +// AngelaMos | 2026 +// config.go + +package config + +import ( + "fmt" + "sync" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` +} + +type AppConfig struct { + Name string `koanf:"name"` + Version string `koanf:"version"` + Environment string `koanf:"environment"` +} + +type ServerConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` +} + +type DatabaseConfig struct { + URL string `koanf:"url"` + MaxOpenConns int `koanf:"max_open_conns"` + MaxIdleConns int `koanf:"max_idle_conns"` + ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"` +} + +type RedisConfig struct { + URL string `koanf:"url"` + PoolSize int `koanf:"pool_size"` + MinIdleConns int `koanf:"min_idle_conns"` +} + +type RateLimitConfig struct { + Requests int `koanf:"requests"` + Window time.Duration `koanf:"window"` + Burst int `koanf:"burst"` +} + +type CORSConfig struct { + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` + MaxAge int `koanf:"max_age"` +} + +type LogConfig struct { + Level string `koanf:"level"` + Format string `koanf:"format"` +} + +type OtelConfig struct { + Endpoint string `koanf:"endpoint"` + ServiceName string `koanf:"service_name"` + Enabled bool `koanf:"enabled"` + Insecure bool `koanf:"insecure"` + SampleRate float64 `koanf:"sample_rate"` +} + +var ( + cfg *Config + once sync.Once +) + +func Load(configPath string) (*Config, error) { + var loadErr error + + once.Do(func() { + k := koanf.New(".") + + if err := loadDefaults(k); err != nil { + loadErr = fmt.Errorf("load defaults: %w", err) + return + } + + if configPath != "" { + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + loadErr = fmt.Errorf("load config file: %w", err) + return + } + } + + if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil { + loadErr = fmt.Errorf("load env vars: %w", err) + return + } + + cfg = &Config{} + if err := k.Unmarshal("", cfg); err != nil { + loadErr = fmt.Errorf("unmarshal config: %w", err) + return + } + + if err := validate(cfg); err != nil { + loadErr = fmt.Errorf("validate config: %w", err) + return + } + }) + + if loadErr != nil { + return nil, loadErr + } + + return cfg, nil +} + +func Get() *Config { + if cfg == nil { + panic("config not loaded: call Load() first") + } + return cfg +} + +func loadDefaults(k *koanf.Koanf) error { + defaults := map[string]any{ + "app.name": "Canary Token Generator", + "app.version": "1.0.0", + "app.environment": "development", + + "server.host": "0.0.0.0", + "server.port": 8080, + "server.read_timeout": "30s", + "server.write_timeout": "30s", + "server.idle_timeout": "120s", + "server.shutdown_timeout": "15s", + + "database.max_open_conns": 25, + "database.max_idle_conns": 5, + "database.conn_max_lifetime": "1h", + "database.conn_max_idle_time": "30m", + + "redis.pool_size": 10, + "redis.min_idle_conns": 5, + + "rate_limit.requests": 100, + "rate_limit.window": "1m", + "rate_limit.burst": 20, + + "cors.allowed_origins": []string{"http://localhost:3000"}, + "cors.allowed_methods": []string{ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + }, + "cors.allowed_headers": []string{ + "Accept", + "Authorization", + "Content-Type", + "X-Request-ID", + }, + "cors.allow_credentials": true, + "cors.max_age": 300, + + "log.level": "info", + "log.format": "json", + + "otel.enabled": false, + "otel.insecure": true, + "otel.sample_rate": 0.1, + "otel.service_name": "canary-token-generator", + } + + for key, value := range defaults { + if err := k.Set(key, value); err != nil { + return fmt.Errorf("set default %s: %w", key, err) + } + } + + return nil +} + +var envKeyMap = map[string]string{ + "DATABASE_URL": "database.url", + "REDIS_URL": "redis.url", + "ENVIRONMENT": "app.environment", + "HOST": "server.host", + "PORT": "server.port", + "LOG_LEVEL": "log.level", + "LOG_FORMAT": "log.format", + "RATE_LIMIT_REQUESTS": "rate_limit.requests", + "RATE_LIMIT_WINDOW": "rate_limit.window", + "RATE_LIMIT_BURST": "rate_limit.burst", + "OTEL_ENDPOINT": "otel.endpoint", + "OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint", + "OTEL_SERVICE_NAME": "otel.service_name", + "OTEL_ENABLED": "otel.enabled", + "OTEL_INSECURE": "otel.insecure", + "OTEL_SAMPLE_RATE": "otel.sample_rate", +} + +func envKeyReplacer(s string) string { + if mapped, ok := envKeyMap[s]; ok { + return mapped + } + return "" +} + +func validate(c *Config) error { + if c.Database.URL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + + if c.Redis.URL == "" { + return fmt.Errorf("REDIS_URL is required") + } + + if c.CORS.AllowCredentials { + for _, origin := range c.CORS.AllowedOrigins { + if origin == "*" { + return fmt.Errorf( + "CORS wildcard '*' cannot be used with AllowCredentials", + ) + } + } + } + + if c.App.Environment == "production" { + if c.Otel.Enabled && c.Otel.Insecure { + return fmt.Errorf("OTEL_INSECURE must be false in production") + } + } + + if c.Server.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive") + } + + if c.Server.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive") + } + + return nil +} + +func (c *Config) IsProduction() bool { + return c.App.Environment == "production" +} + +func (c *Config) IsDevelopment() bool { + return c.App.Environment == "development" +} + +func (s *ServerConfig) Address() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go new file mode 100644 index 00000000..72190b03 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/database.go @@ -0,0 +1,145 @@ +// AngelaMos | 2026 +// database.go + +package core + +import ( + "context" + "database/sql" + "fmt" + "math/rand/v2" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" +) + +type Database struct { + DB *sqlx.DB +} + +func NewDatabase( + ctx context.Context, + cfg config.DatabaseConfig, +) (*Database, error) { + db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL) + if err != nil { + return nil, fmt.Errorf("connect to database: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime)) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() //nolint:errcheck // cleanup on connection failure + return nil, fmt.Errorf("ping database: %w", err) + } + + return &Database{DB: db}, nil +} + +func (d *Database) Close() error { + if d.DB != nil { + return d.DB.Close() + } + return nil +} + +func (d *Database) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := d.DB.PingContext(pingCtx); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + + return nil +} + +func (d *Database) Stats() sql.DBStats { + return d.DB.Stats() +} + +type DBTX interface { + sqlx.ExtContext + sqlx.ExecerContext + GetContext(ctx context.Context, dest any, query string, args ...any) error + SelectContext( + ctx context.Context, + dest any, + query string, + args ...any, + ) error +} + +func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func InTxWithOptions( + ctx context.Context, + db *sqlx.DB, + opts *sql.TxOptions, + fn func(tx *sqlx.Tx) error, +) error { + tx, err := db.BeginTxx(ctx, opts) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func jitteredDuration(base time.Duration) time.Duration { + //nolint:gosec // G404: non-security-sensitive jitter for connection pool + jitter := time.Duration(rand.Int64N(int64(base / 7))) + return base + jitter +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go new file mode 100644 index 00000000..b478a6ee --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/errors.go @@ -0,0 +1,169 @@ +// AngelaMos | 2026 +// errors.go + +package core + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrDuplicateKey = errors.New("duplicate key violation") + ErrForeignKey = errors.New("foreign key violation") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrInternal = errors.New("internal server error") + ErrConflict = errors.New("resource conflict") + ErrRateLimited = errors.New("rate limit exceeded") + ErrTokenExpired = errors.New("token expired") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenRevoked = errors.New("token revoked") +) + +type AppError struct { + Err error `json:"-"` + Message string `json:"message"` + StatusCode int `json:"-"` + Code string `json:"code"` +} + +func (e *AppError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown error" +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +func NewAppError( + err error, + message string, + statusCode int, + code string, +) *AppError { + return &AppError{ + Err: err, + Message: message, + StatusCode: statusCode, + Code: code, + } +} + +func NotFoundError(resource string) *AppError { + return &AppError{ + Err: ErrNotFound, + Message: fmt.Sprintf("%s not found", resource), + StatusCode: http.StatusNotFound, + Code: "NOT_FOUND", + } +} + +func DuplicateError(field string) *AppError { + return &AppError{ + Err: ErrDuplicateKey, + Message: fmt.Sprintf("%s already exists", field), + StatusCode: http.StatusConflict, + Code: "DUPLICATE", + } +} + +func ValidationError(message string) *AppError { + return &AppError{ + Err: ErrInvalidInput, + Message: message, + StatusCode: http.StatusBadRequest, + Code: "VALIDATION_ERROR", + } +} + +func UnauthorizedError(message string) *AppError { + if message == "" { + message = "authentication required" + } + return &AppError{ + Err: ErrUnauthorized, + Message: message, + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + } +} + +func ForbiddenError(message string) *AppError { + if message == "" { + message = "access denied" + } + return &AppError{ + Err: ErrForbidden, + Message: message, + StatusCode: http.StatusForbidden, + Code: "FORBIDDEN", + } +} + +func InternalError(err error) *AppError { + return &AppError{ + Err: err, + Message: "internal server error", + StatusCode: http.StatusInternalServerError, + Code: "INTERNAL_ERROR", + } +} + +func RateLimitError() *AppError { + return &AppError{ + Err: ErrRateLimited, + Message: "too many requests", + StatusCode: http.StatusTooManyRequests, + Code: "RATE_LIMITED", + } +} + +func TokenExpiredError() *AppError { + return &AppError{ + Err: ErrTokenExpired, + Message: "token has expired", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_EXPIRED", + } +} + +func TokenInvalidError() *AppError { + return &AppError{ + Err: ErrTokenInvalid, + Message: "invalid token", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_INVALID", + } +} + +func TokenRevokedError() *AppError { + return &AppError{ + Err: ErrTokenRevoked, + Message: "token has been revoked", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_REVOKED", + } +} + +func IsAppError(err error) bool { + var appErr *AppError + return errors.As(err, &appErr) +} + +func GetAppError(err error) *AppError { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr + } + return InternalError(err) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go new file mode 100644 index 00000000..4391d459 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/redis.go @@ -0,0 +1,63 @@ +// AngelaMos | 2026 +// redis.go + +package core + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" +) + +type Redis struct { + Client *redis.Client +} + +func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { + opts, err := redis.ParseURL(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + opts.PoolSize = cfg.PoolSize + opts.MinIdleConns = cfg.MinIdleConns + opts.PoolTimeout = 30 * time.Second + opts.ConnMaxIdleTime = 5 * time.Minute + + client := redis.NewClient(opts) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + if r.Client != nil { + return r.Client.Close() + } + return nil +} + +func (r *Redis) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := r.Client.Ping(pingCtx).Err(); err != nil { + return fmt.Errorf("redis ping failed: %w", err) + } + + return nil +} + +func (r *Redis) PoolStats() *redis.PoolStats { + return r.Client.PoolStats() +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go new file mode 100644 index 00000000..e37ac737 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/response.go @@ -0,0 +1,120 @@ +// AngelaMos | 2026 +// response.go + +package core + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +type Response struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Meta struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Total int `json:"total,omitempty"` + TotalPages int `json:"total_pages,omitempty"` +} + +func JSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: status >= 200 && status < 300, + Data: data, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: true, + Data: data, + Meta: meta, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONError(w http.ResponseWriter, err error) { + appErr := GetAppError(err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(appErr.StatusCode) + + response := Response{ + Success: false, + Error: &Error{ + Code: appErr.Code, + Message: appErr.Message, + }, + } + + if encErr := json.NewEncoder(w).Encode(response); encErr != nil { + slog.Error("failed to encode error response", "error", encErr) + } +} + +func Created(w http.ResponseWriter, data any) { + JSON(w, http.StatusCreated, data) +} + +func OK(w http.ResponseWriter, data any) { + JSON(w, http.StatusOK, data) +} + +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +func BadRequest(w http.ResponseWriter, message string) { + JSONError(w, ValidationError(message)) +} + +func NotFound(w http.ResponseWriter, resource string) { + JSONError(w, NotFoundError(resource)) +} + +func Unauthorized(w http.ResponseWriter, message string) { + JSONError(w, UnauthorizedError(message)) +} + +func Forbidden(w http.ResponseWriter, message string) { + JSONError(w, ForbiddenError(message)) +} + +func InternalServerError(w http.ResponseWriter, err error) { + slog.Error("internal server error", "error", err) + JSONError(w, InternalError(err)) +} + +func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) { + totalPages := (total + pageSize - 1) / pageSize + JSONWithMeta(w, http.StatusOK, data, &Meta{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/security.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/security.go new file mode 100644 index 00000000..05065e1a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/security.go @@ -0,0 +1,218 @@ +// AngelaMos | 2026 +// security.go + +package core + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + saltLength = 16 +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + argonTime, + argonMemory, + argonThreads, + argonKeyLen, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encoded := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, + argonTime, + argonThreads, + b64Salt, + b64Hash, + ) + + return encoded, nil +} + +func VerifyPassword(password, encodedHash string) (bool, error) { + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey( + []byte(password), + salt, + params.time, + params.memory, + params.threads, + params.keyLen, + ) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func VerifyPasswordWithRehash( + password, encodedHash string, +) (bool, string, error) { + valid, err := VerifyPassword(password, encodedHash) + if err != nil { + return false, "", err + } + + if !valid { + return false, "", nil + } + + if needsRehash(encodedHash) { + newHash, hashErr := HashPassword(password) + if hashErr != nil { + //nolint:nilerr // password verified successfully; rehash failure is non-critical + return true, "", nil + } + return true, newHash, nil + } + + return true, "", nil +} + +var dummyHash string + +func init() { + hash, err := HashPassword("dummy_password_for_timing_attack_prevention") + if err != nil { + panic(fmt.Sprintf("security: failed to generate dummy hash: %v", err)) + } + dummyHash = hash +} + +func VerifyPasswordTimingSafe( + password string, + encodedHash *string, +) (bool, string, error) { + hashToVerify := dummyHash + if encodedHash != nil && *encodedHash != "" { + hashToVerify = *encodedHash + } + + valid, newHash, err := VerifyPasswordWithRehash(password, hashToVerify) + + if encodedHash == nil || *encodedHash == "" { + return false, "", nil + } + + return valid, newHash, err +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format") + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("unsupported algorithm: %s", parts[1]) + } + + var version int + _, err := fmt.Sscanf(parts[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid version: %w", err) + } + + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("incompatible version: %d", version) + } + + params := &argonParams{} + _, err = fmt.Sscanf( + parts[3], + "m=%d,t=%d,p=%d", + ¶ms.memory, + ¶ms.time, + ¶ms.threads, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode salt: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode hash: %w", err) + } + + //nolint:gosec // G115: hash length is always small (32 bytes for Argon2id) + params.keyLen = uint32(len(hash)) + + return params, salt, hash, nil +} + +func needsRehash(encodedHash string) bool { + params, _, _, err := decodeHash(encodedHash) + if err != nil { + return true + } + + return params.memory != argonMemory || + params.time != argonTime || + params.threads != argonThreads || + params.keyLen != argonKeyLen +} + +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func GenerateRefreshToken() (string, error) { + return GenerateSecureToken(32) +} + +func HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func CompareTokenHash(token, hash string) bool { + tokenHash := HashToken(token) + return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go new file mode 100644 index 00000000..16bfb56d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/telemetry.go @@ -0,0 +1,142 @@ +// AngelaMos | 2026 +// telemetry.go + +package core + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" +) + +type Telemetry struct { + TracerProvider *sdktrace.TracerProvider + Tracer trace.Tracer +} + +func NewTelemetry( + ctx context.Context, + otelCfg config.OtelConfig, + appCfg config.AppConfig, +) (*Telemetry, error) { + if !otelCfg.Enabled || otelCfg.Endpoint == "" { + noopProvider := sdktrace.NewTracerProvider() + return &Telemetry{ + TracerProvider: noopProvider, + Tracer: noopProvider.Tracer(otelCfg.ServiceName), + }, nil + } + + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(otelCfg.Endpoint), + otlptracegrpc.WithTimeout(5 * time.Second), + } + + if otelCfg.Insecure { + opts = append( + opts, + otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), + ) + } else { + opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))) + } + + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create otlp exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(appCfg.Version), + attribute.String("environment", appCfg.Environment), + ), + resource.WithHost(), + resource.WithProcess(), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + + sampleRate := otelCfg.SampleRate + if sampleRate <= 0 || sampleRate > 1 { + sampleRate = 0.1 + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBatchTimeout(5*time.Second), + sdktrace.WithMaxExportBatchSize(512), + ), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased( + sdktrace.TraceIDRatioBased(sampleRate), + )), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Telemetry{ + TracerProvider: tp, + Tracer: tp.Tracer(otelCfg.ServiceName), + }, nil +} + +func (t *Telemetry) Shutdown(ctx context.Context) error { + if t.TracerProvider == nil { + return nil + } + + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown tracer provider: %w", err) + } + + return nil +} + +func SpanFromContext(ctx context.Context) trace.Span { + return trace.SpanFromContext(ctx) +} + +func TraceIDFromContext(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + return span.SpanContext().TraceID().String() + } + return "" +} + +func AddSpanEvent( + ctx context.Context, + name string, + attrs ...attribute.KeyValue, +) { + span := trace.SpanFromContext(ctx) + span.AddEvent(name, trace.WithAttributes(attrs...)) +} + +func SetSpanError(ctx context.Context, err error) { + span := trace.SpanFromContext(ctx) + span.RecordError(err) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go b/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go new file mode 100644 index 00000000..f77cbd96 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/core/validation.go @@ -0,0 +1,42 @@ +// AngelaMos | 2026 +// validation.go + +package core + +import ( + "errors" + "strings" + + "github.com/go-playground/validator/v10" +) + +func FormatValidationError(err error) string { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + messages := make([]string, 0, len(ve)) + for _, fe := range ve { + messages = append(messages, FormatFieldError(fe)) + } + return strings.Join(messages, "; ") + } + return "validation failed" +} + +func FormatFieldError(fe validator.FieldError) string { + field := strings.ToLower(fe.Field()) + + switch fe.Tag() { + case "required": + return field + " is required" + case "email": + return field + " must be a valid email" + case "min": + return field + " must be at least " + fe.Param() + " characters" + case "max": + return field + " must be at most " + fe.Param() + " characters" + case "oneof": + return field + " must be one of: " + fe.Param() + default: + return field + " is invalid" + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go new file mode 100644 index 00000000..e7003c6a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/health/handler.go @@ -0,0 +1,195 @@ +// AngelaMos | 2026 +// handler.go + +package health + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" +) + +type Checker interface { + Ping(ctx context.Context) error +} + +type Handler struct { + db Checker + redis Checker + ready atomic.Bool + shutdown atomic.Bool +} + +func NewHandler(db, redis Checker) *Handler { + h := &Handler{ + db: db, + redis: redis, + } + h.ready.Store(true) + return h +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/healthz", h.Liveness) + r.Get("/livez", h.Liveness) + r.Get("/readyz", h.Readiness) +} + +func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + h.writeStatus(w, http.StatusOK, StatusResponse{ + Status: "ok", + }) +} + +func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + if !h.ready.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "not_ready", + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + checks := h.runHealthChecks(ctx) + + allHealthy := true + for _, check := range checks { + if !check.Healthy { + allHealthy = false + break + } + } + + status := "ok" + statusCode := http.StatusOK + if !allHealthy { + status = "degraded" + statusCode = http.StatusServiceUnavailable + } + + h.writeStatus(w, statusCode, ReadinessResponse{ + Status: status, + Checks: checks, + }) +} + +func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck { + var wg sync.WaitGroup + checks := make([]HealthCheck, 2) + + wg.Add(2) + + go func() { + defer wg.Done() + checks[0] = h.checkDatabase(ctx) + }() + + go func() { + defer wg.Done() + checks[1] = h.checkRedis(ctx) + }() + + wg.Wait() + return checks +} + +func (h *Handler) checkDatabase(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "database", + Healthy: true, + } + + if h.db == nil { + check.Healthy = false + check.Message = "database checker not configured" + return check + } + + start := time.Now() + err := h.db.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) checkRedis(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "redis", + Healthy: true, + } + + if h.redis == nil { + check.Healthy = false + check.Message = "redis checker not configured" + return check + } + + start := time.Now() + err := h.redis.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) SetReady(ready bool) { + h.ready.Store(ready) +} + +func (h *Handler) SetShutdown(shutdown bool) { + h.shutdown.Store(shutdown) +} + +func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(status) + //nolint:errcheck // best-effort response + _ = json.NewEncoder(w).Encode(data) +} + +type StatusResponse struct { + Status string `json:"status"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Checks []HealthCheck `json:"checks"` +} + +type HealthCheck struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` + Latency string `json:"latency,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go new file mode 100644 index 00000000..20ab3e2a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/headers.go @@ -0,0 +1,103 @@ +// AngelaMos | 2026 +// headers.go + +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" +) + +func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("X-XSS-Protection", "1; mode=block") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ) + + if isProduction { + h.Set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ) + } + + h.Set("Content-Security-Policy", buildCSP(isProduction)) + + next.ServeHTTP(w, r) + }) + } +} + +func buildCSP(isProduction bool) string { + directives := []string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + } + + if !isProduction { + directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + } + + return strings.Join(directives, "; ") +} + +func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler { + allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins)) + for _, origin := range cfg.AllowedOrigins { + allowedOrigins[origin] = struct{}{} + } + + methodsStr := strings.Join(cfg.AllowedMethods, ", ") + headersStr := strings.Join(cfg.AllowedHeaders, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" { + if _, ok := allowedOrigins[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + + if cfg.AllowCredentials { + w.Header(). + Set("Access-Control-Allow-Credentials", "true") + } + } + } + + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", methodsStr) + w.Header().Set("Access-Control-Allow-Headers", headersStr) + + if cfg.MaxAge > 0 { + w.Header(). + Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } + + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go new file mode 100644 index 00000000..602ed4c7 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/logging.go @@ -0,0 +1,99 @@ +// AngelaMos | 2026 +// logging.go + +package middleware + +import ( + "context" + "log/slog" + "net/http" + "time" + + "go.opentelemetry.io/otel/trace" +) + +type loggerKey struct{} + +func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + requestID := GetRequestID(r.Context()) + + reqLogger := baseLogger.With( + slog.String("request_id", requestID), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + ) + + if span := trace.SpanFromContext(r.Context()); span.SpanContext(). + IsValid() { + reqLogger = reqLogger.With( + slog.String( + "trace_id", + span.SpanContext().TraceID().String(), + ), + slog.String( + "span_id", + span.SpanContext().SpanID().String(), + ), + ) + } + + ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger) + + ww := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(ww, r.WithContext(ctx)) + + latency := time.Since(start) + + logLevel := slog.LevelInfo + if ww.status >= 500 { + logLevel = slog.LevelError + } else if ww.status >= 400 { + logLevel = slog.LevelWarn + } + + reqLogger.Log(r.Context(), logLevel, "request completed", + slog.Int("status", ww.status), + slog.Int("bytes", ww.bytes), + slog.Duration("latency", latency), + slog.String("user_agent", r.UserAgent()), + ) + }) + } +} + +func GetLogger(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +type responseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.bytes += n + return n, err +} + +func (rw *responseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go new file mode 100644 index 00000000..4acd812a --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go @@ -0,0 +1,272 @@ +// AngelaMos | 2026 +// ratelimit.go + +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + redis_rate "github.com/go-redis/redis_rate/v10" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +type RateLimitConfig struct { + Limit redis_rate.Limit + KeyFunc func(*http.Request) string + FailOpen bool + BypassFunc func(*http.Request) bool + OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result) +} + +type RateLimiter struct { + limiter *redis_rate.Limiter + fallback *localLimiter + config RateLimitConfig +} + +func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter { + if cfg.KeyFunc == nil { + cfg.KeyFunc = KeyByIP + } + + return &RateLimiter{ + limiter: redis_rate.NewLimiter(rdb), + fallback: newLocalLimiter(), + config: cfg, + } +} + +func (rl *RateLimiter) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) { + next.ServeHTTP(w, r) + return + } + + key := rl.config.KeyFunc(r) + res, err := rl.allow(r.Context(), key) + if err != nil { + if rl.config.FailOpen { + slog.Warn("rate limiter error, failing open", + "error", err, + "key", key, + ) + next.ServeHTTP(w, r) + return + } + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + + setRateLimitHeaders(w, res, rl.config.Limit) + + if res.Allowed == 0 { + if rl.config.OnLimited != nil { + rl.config.OnLimited(w, r, res) + return + } + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow( + ctx context.Context, + key string, +) (*redis_rate.Result, error) { + res, err := rl.limiter.Allow(ctx, key, rl.config.Limit) + if err != nil { + return rl.fallback.allow(key, rl.config.Limit) + } + return res, nil +} + +func KeyByIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + ip := strings.TrimSpace(ips[len(ips)-1]) + return "ratelimit:ip:" + ip + } + + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return "ratelimit:ip:" + xri + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + + return "ratelimit:ip:" + ip +} + +func setRateLimitHeaders( + w http.ResponseWriter, + res *redis_rate.Result, + limit redis_rate.Limit, +) { + h := w.Header() + + h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate)) + h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining)) + h.Set("X-RateLimit-Reset", strconv.FormatInt( + time.Now().Add(res.ResetAfter).Unix(), 10)) + + windowSecs := int(limit.Period.Seconds()) + h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs)) + h.Set( + "RateLimit", + fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())), + ) +} + +func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) { + retryAfter := int(res.RetryAfter.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + + response := map[string]any{ + "success": false, + "error": map[string]any{ + "code": "RATE_LIMITED", + "message": fmt.Sprintf( + "Rate limit exceeded. Retry after %d seconds.", + retryAfter, + ), + }, + } + + //nolint:errcheck // best-effort response write + _ = json.NewEncoder(w).Encode(response) +} + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess int64 +} + +type localLimiter struct { + limiters sync.Map +} + +const ( + cleanupInterval = 5 * time.Minute + entryTTL = 10 * time.Minute +) + +func newLocalLimiter() *localLimiter { + l := &localLimiter{} + go l.cleanup() + return l +} + +func (l *localLimiter) cleanup() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for range ticker.C { + cutoff := time.Now().Add(-entryTTL).Unix() + l.limiters.Range(func(key, value any) bool { + entry, ok := value.(*limiterEntry) + if ok && entry.lastAccess < cutoff { + l.limiters.Delete(key) + } + return true + }) + } +} + +func (l *localLimiter) allow( + key string, + limit redis_rate.Limit, +) (*redis_rate.Result, error) { + ratePerSec := float64(limit.Rate) / limit.Period.Seconds() + now := time.Now().Unix() + + entryI, loaded := l.limiters.Load(key) + if !loaded { + newEntry := &limiterEntry{ + limiter: rate.NewLimiter( + rate.Limit(ratePerSec), + limit.Burst, + ), + lastAccess: now, + } + entryI, _ = l.limiters.LoadOrStore(key, newEntry) + } + + entry, ok := entryI.(*limiterEntry) + if !ok { + return nil, fmt.Errorf("invalid limiter entry type") + } + entry.lastAccess = now + + allowed := entry.limiter.Allow() + + remaining := int(entry.limiter.Tokens()) + if remaining < 0 { + remaining = 0 + } + + var retryAfter time.Duration + if !allowed { + retryAfter = time.Duration(float64(time.Second) / ratePerSec) + } else { + retryAfter = -1 + } + + allowedInt := 0 + if allowed { + allowedInt = 1 + } + + return &redis_rate.Result{ + Limit: limit, + Allowed: allowedInt, + Remaining: remaining, + RetryAfter: retryAfter, + ResetAfter: time.Duration(float64(time.Second) / ratePerSec), + }, nil +} + +func PerMinute(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Minute, + } +} + +func PerSecond(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Second, + } +} + +func PerHour(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Hour, + } +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go new file mode 100644 index 00000000..77d98165 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/request_id.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// request_id.go + +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +const RequestIDHeader = "X-Request-ID" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(RequestIDHeader) + + if requestID == "" { + requestID = uuid.New().String() + } + + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + + w.Header().Set(RequestIDHeader, requestID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(RequestIDKey).(string); ok { + return id + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go b/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go new file mode 100644 index 00000000..2ebe0730 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/server/server.go @@ -0,0 +1,108 @@ +// AngelaMos | 2026 +// server.go + +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health" +) + +type Server struct { + httpServer *http.Server + router *chi.Mux + config config.ServerConfig + healthHandler *health.Handler + logger *slog.Logger +} + +type Config struct { + ServerConfig config.ServerConfig + HealthHandler *health.Handler + Logger *slog.Logger +} + +func New(cfg Config) *Server { + router := chi.NewRouter() + + router.Use(chimw.CleanPath) + router.Use(chimw.StripSlashes) + + return &Server{ + httpServer: &http.Server{ + Addr: cfg.ServerConfig.Address(), + Handler: router, + ReadTimeout: cfg.ServerConfig.ReadTimeout, + WriteTimeout: cfg.ServerConfig.WriteTimeout, + IdleTimeout: cfg.ServerConfig.IdleTimeout, + }, + router: router, + config: cfg.ServerConfig, + healthHandler: cfg.HealthHandler, + logger: cfg.Logger, + } +} + +func (s *Server) Router() *chi.Mux { + return s.router +} + +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", + "addr", s.config.Address(), + "read_timeout", s.config.ReadTimeout, + "write_timeout", s.config.WriteTimeout, + "idle_timeout", s.config.IdleTimeout, + ) + + if err := s.httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server error: %w", err) + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error { + s.logger.Info("initiating graceful shutdown") + + s.logger.Info("marking server as not ready") + if s.healthHandler != nil { + s.healthHandler.SetReady(false) + s.healthHandler.SetShutdown(true) + } + + s.logger.Info("waiting for load balancer to drain", + "delay", drainDelay, + ) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(drainDelay): + } + + s.logger.Info("stopping HTTP server") + shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http server shutdown: %w", err) + } + + s.logger.Info("HTTP server stopped gracefully") + return nil +} + +func (s *Server) Address() string { + return s.httpServer.Addr +}