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