// Package restapi exposes the unauthenticated public REST surface of the // gateway. package restapi import ( "context" "errors" "fmt" "net" "net/http" "sync" "galaxy/gateway/internal/config" "galaxy/gateway/internal/telemetry" "github.com/gin-gonic/gin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.uber.org/zap" ) const ( jsonContentType = "application/json; charset=utf-8" errorCodeInvalidRequest = "invalid_request" errorCodeNotFound = "not_found" errorCodeMethodNotAllowed = "method_not_allowed" errorCodeInternalError = "internal_error" errorCodeServiceUnavailable = "service_unavailable" publicRESTBaseBucketKeyPrefix = "public_rest/class=" ) // PublicRouteClass identifies the public traffic class assigned to an incoming // REST request before route handling and edge policy evaluation. type PublicRouteClass string const ( // PublicRouteClassPublicAuth identifies public authentication commands. PublicRouteClassPublicAuth PublicRouteClass = "public_auth" // PublicRouteClassBrowserBootstrap identifies browser bootstrap traffic such // as the main document request. PublicRouteClassBrowserBootstrap PublicRouteClass = "browser_bootstrap" // PublicRouteClassBrowserAsset identifies browser asset requests. PublicRouteClassBrowserAsset PublicRouteClass = "browser_asset" // PublicRouteClassPublicMisc identifies public traffic that does not match a // more specific class. PublicRouteClassPublicMisc PublicRouteClass = "public_misc" ) var configureGinModeOnce sync.Once // Normalized returns c when it belongs to the stable public route class set. // Unknown or empty values collapse to PublicRouteClassPublicMisc so edge policy // code can rely on a fixed anti-abuse namespace. func (c PublicRouteClass) Normalized() PublicRouteClass { switch c { case PublicRouteClassPublicAuth, PublicRouteClassBrowserBootstrap, PublicRouteClassBrowserAsset, PublicRouteClassPublicMisc: return c default: return PublicRouteClassPublicMisc } } // BaseBucketKey returns the canonical base rate-limit namespace for c. The key // stays scoped only by the normalized public route class; callers may append // subject dimensions such as IP or identity without redefining the class // namespace. func (c PublicRouteClass) BaseBucketKey() string { return publicRESTBaseBucketKeyPrefix + string(c.Normalized()) } // PublicTrafficClassifier maps public REST requests to the public anti-abuse // class used by the gateway edge. The server normalizes classifier outputs to // the stable class set before storing them in request context. type PublicTrafficClassifier interface { Classify(*http.Request) PublicRouteClass } // ServerDependencies describes the optional collaborators used by the public // REST server. The zero value is valid and keeps the process runnable with the // built-in defaults. type ServerDependencies struct { // Classifier assigns the public anti-abuse class before route handling. // When nil, the gateway default classifier is used. Classifier PublicTrafficClassifier // AuthService delegates public auth commands to the Auth / Session Service. // When nil, public auth routes remain mounted and return a stable // service-unavailable response. AuthService AuthServiceClient // Limiter applies the public REST rate-limit policy. When nil, a default // process-local in-memory limiter is used. Limiter PublicRequestLimiter // Observer records malformed-request telemetry for the public REST layer. // When nil, a no-op observer is used. Observer PublicRequestObserver // Logger writes structured transport logs for public REST traffic. When nil, // a no-op logger is used. Logger *zap.Logger // Telemetry records low-cardinality edge metrics. When nil, metrics are // disabled. Telemetry *telemetry.Runtime } // Server owns the public unauthenticated REST listener exposed by the gateway. type Server struct { cfg config.PublicHTTPConfig handler http.Handler logger *zap.Logger stateMu sync.RWMutex server *http.Server listener net.Listener } // NewServer constructs a public REST server for the supplied listener // configuration and dependency bundle. Nil dependencies are replaced with safe // defaults so the gateway can still expose the documented public surface. func NewServer(cfg config.PublicHTTPConfig, deps ServerDependencies) *Server { deps = normalizeServerDependencies(deps) return &Server{ cfg: cfg, handler: newPublicHandlerWithConfig(cfg, deps), logger: deps.Logger.Named("public_http"), } } // Run binds the configured listener and serves the public REST surface until // Shutdown closes the server. func (s *Server) Run(ctx context.Context) error { if ctx == nil { return errors.New("run public REST server: nil context") } if err := ctx.Err(); err != nil { return err } listener, err := net.Listen("tcp", s.cfg.Addr) if err != nil { return fmt.Errorf("run public REST server: listen on %q: %w", s.cfg.Addr, err) } server := &http.Server{ Handler: s.handler, ReadHeaderTimeout: s.cfg.ReadHeaderTimeout, ReadTimeout: s.cfg.ReadTimeout, IdleTimeout: s.cfg.IdleTimeout, } s.stateMu.Lock() s.server = server s.listener = listener s.stateMu.Unlock() s.logger.Info("public REST server started", zap.String("addr", listener.Addr().String())) defer func() { s.stateMu.Lock() s.server = nil s.listener = nil s.stateMu.Unlock() }() err = server.Serve(listener) switch { case err == nil: return nil case errors.Is(err, http.ErrServerClosed): s.logger.Info("public REST server stopped") return nil default: return fmt.Errorf("run public REST server: serve on %q: %w", s.cfg.Addr, err) } } // Shutdown gracefully stops the public REST server within ctx. func (s *Server) Shutdown(ctx context.Context) error { if ctx == nil { return errors.New("shutdown public REST server: nil context") } s.stateMu.RLock() server := s.server s.stateMu.RUnlock() if server == nil { return nil } if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("shutdown public REST server: %w", err) } return nil } // PublicRouteClassFromContext returns the previously classified normalized // public route class stored in ctx. func PublicRouteClassFromContext(ctx context.Context) (PublicRouteClass, bool) { if ctx == nil { return "", false } class, ok := ctx.Value(publicRouteClassContextKey{}).(PublicRouteClass) if !ok { return "", false } return class.Normalized(), true } type publicRouteClassContextKey struct{} type defaultPublicTrafficClassifier struct{} // Classify maps the incoming request into a stable public route class that can // later drive anti-abuse policy and rate limiting. func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass { switch { case isPublicAuthRequest(r): return PublicRouteClassPublicAuth case isBrowserBootstrapRequest(r): return PublicRouteClassBrowserBootstrap case isBrowserAssetRequest(r): return PublicRouteClassBrowserAsset default: return PublicRouteClassPublicMisc } } func normalizeServerDependencies(deps ServerDependencies) ServerDependencies { if deps.Classifier == nil { deps.Classifier = defaultPublicTrafficClassifier{} } if deps.AuthService == nil { deps.AuthService = unavailableAuthServiceClient{} } if deps.Limiter == nil { deps.Limiter = newInMemoryPublicRequestLimiter() } if deps.Observer == nil { deps.Observer = noopPublicRequestObserver{} } if deps.Logger == nil { deps.Logger = zap.NewNop() } return deps } func newPublicHandler(deps ServerDependencies) http.Handler { return newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), deps) } func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependencies) http.Handler { configureGinModeOnce.Do(func() { gin.SetMode(gin.ReleaseMode) }) deps = normalizeServerDependencies(deps) router := gin.New() router.HandleMethodNotAllowed = true router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) { abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error") })) router.Use(otelgin.Middleware("galaxy-edge-gateway-public")) router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry)) // CORS runs before the route classifier and anti-abuse layers so // preflight OPTIONS calls answer with 204 immediately and never // count against any rate-limit bucket. router.Use(withCORS(cfg.CORSAllowedOrigins)) router.Use(withPublicRouteClass(deps.Classifier)) router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer)) router.GET("/healthz", handleHealthz) router.GET("/readyz", handleReadyz) router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) router.NoMethod(func(c *gin.Context) { allowMethods := allowedMethodsForPath(c.Request.URL.Path) if allowMethods != "" { c.Header("Allow", allowMethods) } abortWithError(c, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "request method is not allowed for this route") }) router.NoRoute(func(c *gin.Context) { abortWithError(c, http.StatusNotFound, errorCodeNotFound, "resource was not found") }) return router } func handleHealthz(c *gin.Context) { c.JSON(http.StatusOK, statusResponse{Status: "ok"}) } func handleReadyz(c *gin.Context) { c.JSON(http.StatusOK, statusResponse{Status: "ready"}) } func withPublicRouteClass(classifier PublicTrafficClassifier) gin.HandlerFunc { return func(c *gin.Context) { class := classifier.Classify(c.Request).Normalized() ctx := context.WithValue(c.Request.Context(), publicRouteClassContextKey{}, class) c.Request = c.Request.WithContext(ctx) c.Next() } } func isPublicAuthRequest(r *http.Request) bool { return r.Method == http.MethodPost && isPublicAuthPath(r.URL.Path) } func isBrowserBootstrapRequest(r *http.Request) bool { if r.Method == http.MethodGet && r.URL.Path == "/" { return true } return matchesBrowserBootstrapRequestShape(r) } func isBrowserAssetRequest(r *http.Request) bool { if r.Method != http.MethodGet && r.Method != http.MethodHead { return false } return matchesBrowserAssetRequestShape(r) } type statusResponse struct { Status string `json:"status"` } type errorResponse struct { Error errorBody `json:"error"` } type errorBody struct { Code string `json:"code"` Message string `json:"message"` } func abortWithError(c *gin.Context, statusCode int, code string, message string) { if c != nil { c.Set(publicErrorCodeContextKey, code) } c.AbortWithStatusJSON(statusCode, errorResponse{ Error: errorBody{ Code: code, Message: message, }, }) } const publicErrorCodeContextKey = "public_error_code" func allowedMethodsForPath(requestPath string) string { switch requestPath { case "/healthz", "/readyz": return http.MethodGet case "/api/v1/public/auth/send-email-code", "/api/v1/public/auth/confirm-email-code": return http.MethodPost default: return "" } } func (s *Server) listenAddr() string { s.stateMu.RLock() defer s.stateMu.RUnlock() if s.listener == nil { return "" } return s.listener.Addr().String() }