feat(canary): backend bootstrap — strip JWT/users, rename module, rewrite main

Removes the template's JWT auth + user domain (Phase 0 §0.4):
- Deleted backend/internal/auth/ (entire JWT auth domain)
- Deleted backend/internal/user/ (user CRUD domain)
- Deleted backend/keys/ (JWT signing keys directory)
- Deleted backend/internal/middleware/auth.go (Authenticator + RequireAdmin)
- Deleted Justfile generate-keys recipe (no JWT keys to generate)
- Removed lestrrat-go/jwx/v3 + transitive deps via go mod tidy
- Stripped JWTConfig type, defaults, env mappings, validators from config.go
- Removed jwt: section from config.yaml
- Removed JWT_* lines from backend/.env and .env.example

Renames Go module to project-local path (Phase 0 §0.5):
  github.com/carterperez-dev/templates/go-backend
  → github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend
- Rewrote imports in 6 remaining .go files using Edit tool (NEVER sed per repo rule)
- Updated .golangci.yml local-prefixes + gci section ordering

Renames cmd/api → cmd/canary and rewrites main.go (Phase 0 §0.6):
- mv cmd/api cmd/canary
- Rewrote cmd/canary/main.go as canary bootstrap (config, telemetry, db,
  redis, middleware chain, health, /api stub) — no auth wiring
- Updated .air.toml cmd path
- Updated backend/Justfile run/build targets to cmd/canary + bin/canary
- Renamed docker-build image tag to canary-token-generator:latest

Rebrands defaults:
- config.go: app.name "Go Backend" → "Canary Token Generator"
- config.go: otel.service_name "go-backend" → "canary-token-generator"
- config.yaml: app.name "Go Backend Template" → "Canary Token Generator"
- backend/.env(.example): OTEL_SERVICE_NAME → canary-token-generator

Drops user-aware rate-limit helpers from middleware/ratelimit.go:
- Removed KeyByUser, KeyByUserAndEndpoint, normalizeEndpoint, isUUID, isNumeric
- Removed TierConfig, DefaultTiers, TieredRateLimiter (referenced GetUserID)
- KeyByIP, NewRateLimiter, PerMinute/PerSecond/PerHour preserved

Verification:
- go build ./...    — clean
- go vet ./...      — clean
- go mod tidy       — silent (deps consolidated)
- grep -r "carterperez-dev/templates|JWTConfig|JWT_PRIVATE|lestrrat" → empty

backend/compose.yml + backend/dev.compose.yml are still present here; they
get merged into project-root compose files in Task 0.7 (next commit).
This commit is contained in:
CarterPerez-dev 2026-05-10 05:22:08 -04:00
parent 382890cb56
commit 7fa2861e7a
27 changed files with 3037 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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*

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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)
}

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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
)

View File

@ -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=

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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,
})
}

View File

@ -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",
&params.memory,
&params.time,
&params.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
}

View File

@ -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)
}

View File

@ -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"
}
}

View File

@ -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"`
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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 ""
}

View File

@ -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
}