1855e43699
Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list on the public REST server so the dev UI on https://www.galaxy.lan can call https://api.galaxy.lan without the browser blocking the cross-origin response. Defaults to empty (no CORS) so the production posture stays closed. The middleware mounts before route classification and anti-abuse, so OPTIONS preflights never charge against per-class rate-limit buckets. `tools/dev-deploy/docker-compose.yml` opts the dev gateway into a single allowed origin (`https://www.galaxy.lan`); local-dev keeps the defaults because Vite proxies through the same origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
393 lines
11 KiB
Go
393 lines
11 KiB
Go
// 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()
|
|
}
|