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:
parent
382890cb56
commit
7fa2861e7a
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue