You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

198 lines
5.5 KiB
Go

package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
redisv9 "github.com/redis/go-redis/v9"
gconfig "knowfoolery/backend/services/gateway-service/internal/infra/config"
"knowfoolery/backend/services/gateway-service/internal/infra/proxy"
httpapi "knowfoolery/backend/services/gateway-service/internal/interfaces/http"
"knowfoolery/backend/services/gateway-service/internal/interfaces/http/middleware"
"knowfoolery/backend/shared/infra/auth/zitadel"
"knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/observability/tracing"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
cfg := gconfig.FromEnv()
logger := logging.NewLogger(cfg.Logging)
if err := cfg.Upstreams.Validate(); err != nil {
logger.WithError(err).Fatal("invalid gateway upstream configuration")
}
sharedmetrics.NewMetrics(cfg.Metrics)
tracer, err := tracing.NewTracer(cfg.Tracing)
if err != nil {
logger.Fatal("failed to initialize tracer")
}
defer func() {
_ = tracer.Shutdown(context.Background())
}()
redisClient := initRedis(cfg, logger)
if redisClient != nil {
defer func() { _ = redisClient.Close() }()
}
bootCfg := serviceboot.Config{
AppName: cfg.AppName,
ServiceSlug: "gateway",
PortEnv: "GATEWAY_INTERNAL_PORT",
DefaultPort: cfg.Port,
}
app := serviceboot.NewFiberApp(bootCfg)
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
serviceboot.RegisterReadiness(app, 2*time.Second, readinessChecks(cfg, redisClient)...)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
app.Use(middleware.RequestContext(logger))
app.Use(middleware.SecurityHeaders(cfg.Security))
app.Use(middleware.CORS(cfg.CORS))
authMiddleware := buildAuthMiddleware(cfg)
rateLimitMiddleware := middleware.RateLimitMiddleware(redisClient, cfg.Rate, cfg.PublicPrefix, logger)
httpapi.RegisterRoutes(app, httpapi.Options{
PublicPrefix: cfg.PublicPrefix,
Upstreams: cfg.Upstreams,
Proxy: proxy.New(cfg.UpstreamTimeout, logger),
AuthMiddleware: authMiddleware,
RateLimitMiddleware: rateLimitMiddleware,
})
addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}
func initRedis(cfg gconfig.Config, logger *logging.Logger) *redisv9.Client {
opt := &redisv9.Options{
Addr: cfg.Redis.Addr(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}
client := redisv9.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
logger.WithError(err).Warn("redis unavailable; rate limiting running in degraded fail-open mode")
_ = client.Close()
return nil
}
return client
}
func readinessChecks(cfg gconfig.Config, redisClient *redisv9.Client) []serviceboot.ReadyCheck {
checks := []serviceboot.ReadyCheck{
{
Name: "redis",
Required: false,
Probe: func(ctx context.Context) error {
if redisClient == nil {
return fmt.Errorf("redis unavailable")
}
return redisClient.Ping(ctx).Err()
},
},
{
Name: "game-session",
Required: false,
Probe: makeHealthProbe(cfg.Upstreams.GameSession, cfg.UpstreamTimeout),
},
{
Name: "question-bank",
Required: false,
Probe: makeHealthProbe(cfg.Upstreams.QuestionBank, cfg.UpstreamTimeout),
},
{
Name: "user",
Required: false,
Probe: makeHealthProbe(cfg.Upstreams.User, cfg.UpstreamTimeout),
},
{
Name: "leaderboard",
Required: false,
Probe: makeHealthProbe(cfg.Upstreams.Leaderboard, cfg.UpstreamTimeout),
},
{
Name: "admin",
Required: false,
Probe: makeHealthProbe(cfg.Upstreams.Admin, cfg.UpstreamTimeout),
},
}
return checks
}
func makeHealthProbe(baseURL string, timeout time.Duration) func(ctx context.Context) error {
if timeout <= 0 {
timeout = 2 * time.Second
}
return func(ctx context.Context) error {
healthURL := strings.TrimRight(baseURL, "/") + "/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("upstream returned %d", resp.StatusCode)
}
return nil
}
}
func buildAuthMiddleware(cfg gconfig.Config) fiber.Handler {
skipPaths := []string{
"/health",
"/ready",
"/metrics",
cfg.PublicPrefix + "/questions",
cfg.PublicPrefix + "/leaderboard/top10",
cfg.PublicPrefix + "/leaderboard/stats",
cfg.PublicPrefix + "/admin/auth",
cfg.PublicPrefix + "/users/register",
cfg.PublicPrefix + "/users/verify-email",
}
return zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{
BaseURL: cfg.ZitadelBaseURL,
ClientID: cfg.ZitadelClientID,
ClientSecret: cfg.ZitadelSecret,
Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience,
RequiredClaims: []string{
"sub",
},
AdminEndpoints: []string{cfg.PublicPrefix + "/admin"},
SkipPaths: skipPaths,
Timeout: 10 * time.Second,
})
}