From a2d872bdfc3d2644a1110512e2d896f5fdfed775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 15:09:14 +0300 Subject: [PATCH 1/6] Add Phase 1 API Gateway improvements: timeouts, TLS, trace headers Implements 6 features from the gateway improvement roadmap: - Upstream timeouts (DialTimeout, ResponseHeaderTimeout, IdleConnTimeout) - TLS configuration for backends (TLSSkipVerify, RequireTLS) - W3C TraceContext header propagation (traceparent, tracestate, X-Request-ID) - IP allowlist/denylist support (AllowedCIDRs, BlockedCIDRs) - Request size limits (MaxBodySize) - Per-route rate limiting infrastructure Domain changes: - Added 8 new fields to GatewayRoute struct - Extended CreateRouteParams with all new fields - Updated GetProxy interface to return route reference --- internal/core/domain/gateway.go | 38 ++++++++------ internal/core/ports/gateway.go | 25 +++++++--- internal/core/services/gateway.go | 82 ++++++++++++++++++++++++------- 3 files changed, 104 insertions(+), 41 deletions(-) diff --git a/internal/core/domain/gateway.go b/internal/core/domain/gateway.go index ec3fd3fbd..b6efa6518 100644 --- a/internal/core/domain/gateway.go +++ b/internal/core/domain/gateway.go @@ -9,21 +9,29 @@ import ( // GatewayRoute defines an ingress rule for mapping external HTTP traffic to internal resources. type GatewayRoute struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id"` - TenantID uuid.UUID `json:"tenant_id"` - Name string `json:"name"` - PathPrefix string `json:"path_prefix"` // Legacy: Request path to match (e.g., "/api/v1") - PathPattern string `json:"path_pattern"` // New: Pattern with {params} - PatternType string `json:"pattern_type"` // "prefix" or "pattern" - ParamNames []string `json:"param_names"` // Extracted parameter names - TargetURL string `json:"target_url"` // Internal destination (e.g., "http://service-a:8080") - Methods []string `json:"methods"` // New: HTTP methods to match (empty = all) - StripPrefix bool `json:"strip_prefix"` // If true, removes path_prefix from request before forwarding - RateLimit int `json:"rate_limit"` // Maximum allowed requests per second per IP - Priority int `json:"priority"` // Manual priority for tie-breaking - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TenantID uuid.UUID `json:"tenant_id"` + Name string `json:"name"` + PathPrefix string `json:"path_prefix"` // Legacy: Request path to match (e.g., "/api/v1") + PathPattern string `json:"path_pattern"` // New: Pattern with {params} + PatternType string `json:"pattern_type"` // "prefix" or "pattern" + ParamNames []string `json:"param_names"` // Extracted parameter names + TargetURL string `json:"target_url"` // Internal destination (e.g., "http://service-a:8080") + Methods []string `json:"methods"` // New: HTTP methods to match (empty = all) + StripPrefix bool `json:"strip_prefix"` // If true, removes path_prefix from request before forwarding + RateLimit int `json:"rate_limit"` // Maximum allowed requests per second per IP + DialTimeout int64 `json:"dial_timeout"` // TCP dial timeout in milliseconds + ResponseHeaderTimeout int64 `json:"response_header_timeout"` // Time to receive headers in milliseconds + IdleConnTimeout int64 `json:"idle_conn_timeout"` // Idle connection timeout in milliseconds + TLSSkipVerify bool `json:"tls_skip_verify"` // Skip TLS verification for backend + RequireTLS bool `json:"require_tls"` // Force HTTPS for backend + AllowedCIDRs []string `json:"allowed_cidrs"` // IPs allowed to access (empty = all) + BlockedCIDRs []string `json:"blocked_cidrs"` // IPs blocked from access + MaxBodySize int64 `json:"max_body_size"` // Max request body size in bytes + Priority int `json:"priority"` // Manual priority for tie-breaking + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // RouteMatch represents a successful route pattern match. diff --git a/internal/core/ports/gateway.go b/internal/core/ports/gateway.go index eafe375ea..43e1643dd 100644 --- a/internal/core/ports/gateway.go +++ b/internal/core/ports/gateway.go @@ -28,13 +28,21 @@ type GatewayRepository interface { // CreateRouteParams holds parameters for creating a new route. type CreateRouteParams struct { - Name string - Pattern string - Target string - Methods []string - StripPrefix bool - RateLimit int - Priority int + Name string + Pattern string + Target string + Methods []string + StripPrefix bool + RateLimit int + DialTimeout int64 + ResponseHeaderTimeout int64 + IdleConnTimeout int64 + TLSSkipVerify bool + RequireTLS bool + AllowedCIDRs []string + BlockedCIDRs []string + MaxBodySize int64 + Priority int } // GatewayService provides business logic for managing the API gateway and ingress traffic. @@ -48,5 +56,6 @@ type GatewayService interface { // RefreshRoutes reloads all routes and pre-compiles matchers. RefreshRoutes(ctx context.Context) error // GetProxy finds the appropriate backend for the given path and method. - GetProxy(method, path string) (*httputil.ReverseProxy, map[string]string, bool) + // Returns proxy, route, path params, and found flag. + GetProxy(method, path string) (*httputil.ReverseProxy, *domain.GatewayRoute, map[string]string, bool) } diff --git a/internal/core/services/gateway.go b/internal/core/services/gateway.go index dfdbf8598..1c4d0e24c 100644 --- a/internal/core/services/gateway.go +++ b/internal/core/services/gateway.go @@ -3,8 +3,10 @@ package services import ( "context" + "crypto/tls" "fmt" "log/slog" + "net" "net/http" "net/http/httputil" "net/url" @@ -69,21 +71,29 @@ func (s *GatewayService) CreateRoute(ctx context.Context, params ports.CreateRou } route := &domain.GatewayRoute{ - ID: uuid.New(), - UserID: userID, - TenantID: tenantID, - Name: params.Name, - PathPrefix: params.Pattern, // Use pattern as prefix for backward compatibility where possible - PathPattern: params.Pattern, - PatternType: patternType, - ParamNames: paramNames, - TargetURL: params.Target, - Methods: params.Methods, - StripPrefix: params.StripPrefix, - RateLimit: params.RateLimit, - Priority: params.Priority, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + UserID: userID, + TenantID: tenantID, + Name: params.Name, + PathPrefix: params.Pattern, + PathPattern: params.Pattern, + PatternType: patternType, + ParamNames: paramNames, + TargetURL: params.Target, + Methods: params.Methods, + StripPrefix: params.StripPrefix, + RateLimit: params.RateLimit, + DialTimeout: params.DialTimeout, + ResponseHeaderTimeout: params.ResponseHeaderTimeout, + IdleConnTimeout: params.IdleConnTimeout, + TLSSkipVerify: params.TLSSkipVerify, + RequireTLS: params.RequireTLS, + AllowedCIDRs: params.AllowedCIDRs, + BlockedCIDRs: params.BlockedCIDRs, + MaxBodySize: params.MaxBodySize, + Priority: params.Priority, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } if err := s.repo.CreateRoute(ctx, route); err != nil { @@ -180,6 +190,32 @@ func (s *GatewayService) createReverseProxy(route *domain.GatewayRoute) (*httput } proxy := httputil.NewSingleHostReverseProxy(target) + + // Configure custom transport with timeouts and TLS + dialTimeout := time.Duration(route.DialTimeout) * time.Millisecond + if dialTimeout <= 0 { + dialTimeout = 5 * time.Second + } + responseHeaderTimeout := time.Duration(route.ResponseHeaderTimeout) * time.Millisecond + if responseHeaderTimeout <= 0 { + responseHeaderTimeout = 30 * time.Second + } + idleConnTimeout := time.Duration(route.IdleConnTimeout) * time.Millisecond + if idleConnTimeout <= 0 { + idleConnTimeout = 90 * time.Second + } + + proxy.Transport = &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: dialTimeout, + KeepAlive: 30 * time.Second, + }).DialContext, + ResponseHeaderTimeout: responseHeaderTimeout, + IdleConnTimeout: idleConnTimeout, + TLSClientConfig: s.buildTLSConfig(route), + TLSHandshakeTimeout: 10 * time.Second, + } + originalDirector := proxy.Director proxy.Director = func(req *http.Request) { if route.StripPrefix { @@ -199,6 +235,16 @@ func (s *GatewayService) createReverseProxy(route *domain.GatewayRoute) (*httput return proxy, nil } +func (s *GatewayService) buildTLSConfig(route *domain.GatewayRoute) *tls.Config { + cfg := &tls.Config{ + InsecureSkipVerify: route.TLSSkipVerify, + } + if route.RequireTLS { + cfg.MinVersion = tls.VersionTLS12 + } + return cfg +} + func (s *GatewayService) sortRoutes(routes []*domain.GatewayRoute) { // Sort routes by specificity (longer literal prefixes and higher priority first) sort.Slice(routes, func(i, j int) bool { @@ -210,7 +256,7 @@ func (s *GatewayService) sortRoutes(routes []*domain.GatewayRoute) { // ProxyHandler is handled in the API layer for now -func (s *GatewayService) GetProxy(method, path string) (*httputil.ReverseProxy, map[string]string, bool) { +func (s *GatewayService) GetProxy(method, path string) (*httputil.ReverseProxy, *domain.GatewayRoute, map[string]string, bool) { s.proxyMu.RLock() defer s.proxyMu.RUnlock() @@ -226,10 +272,10 @@ func (s *GatewayService) GetProxy(method, path string) (*httputil.ReverseProxy, } if bestMatch != nil { - return s.proxies[bestMatch.Route.ID], bestMatch.Params, true + return s.proxies[bestMatch.Route.ID], bestMatch.Route, bestMatch.Params, true } - return nil, nil, false + return nil, nil, nil, false } func (s *GatewayService) checkRouteMatch(route *domain.GatewayRoute, method, path string) *domain.RouteMatch { From bd9b5df5079301cff88d352b3fc98373c632474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 15:09:30 +0300 Subject: [PATCH 2/6] Add gateway handler with CIDR check, trace injection, and body limit - injectTraceHeaders: adds X-Request-ID and W3C TraceContext headers - checkCIDR: validates client IP against route allowlist/blocklist - limitedReader: enforces MaxBodySize on incoming requests - Updated Proxy to apply all Phase 1 middleware checks - Updated CreateRouteRequest with all new JSON fields - Fixed mock GetProxy signature to match interface change --- internal/handlers/gateway_handler.go | 150 +++++++++++++++++++--- internal/handlers/gateway_handler_test.go | 28 ++-- 2 files changed, 149 insertions(+), 29 deletions(-) diff --git a/internal/handlers/gateway_handler.go b/internal/handlers/gateway_handler.go index 1f4b45382..d9b51c8fa 100644 --- a/internal/handlers/gateway_handler.go +++ b/internal/handlers/gateway_handler.go @@ -2,11 +2,17 @@ package httphandlers import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/poyrazk/thecloud/internal/core/domain" "github.com/poyrazk/thecloud/internal/core/ports" "github.com/poyrazk/thecloud/internal/errors" "github.com/poyrazk/thecloud/pkg/httputil" @@ -14,13 +20,21 @@ import ( // CreateRouteRequest define the payload for creating a route. type CreateRouteRequest struct { - Name string `json:"name" binding:"required"` - PathPrefix string `json:"path_prefix" binding:"required"` - TargetURL string `json:"target_url" binding:"required"` - Methods []string `json:"methods"` - StripPrefix bool `json:"strip_prefix"` - RateLimit int `json:"rate_limit"` - Priority int `json:"priority"` + Name string `json:"name" binding:"required"` + PathPrefix string `json:"path_prefix" binding:"required"` + TargetURL string `json:"target_url" binding:"required"` + Methods []string `json:"methods"` + StripPrefix bool `json:"strip_prefix"` + RateLimit int `json:"rate_limit"` + DialTimeout int64 `json:"dial_timeout"` + ResponseHeaderTimeout int64 `json:"response_header_timeout"` + IdleConnTimeout int64 `json:"idle_conn_timeout"` + TLSSkipVerify bool `json:"tls_skip_verify"` + RequireTLS bool `json:"require_tls"` + AllowedCIDRs []string `json:"allowed_cidrs"` + BlockedCIDRs []string `json:"blocked_cidrs"` + MaxBodySize int64 `json:"max_body_size"` + Priority int `json:"priority"` } // GatewayHandler handles API gateway HTTP endpoints. @@ -57,13 +71,21 @@ func (h *GatewayHandler) CreateRoute(c *gin.Context) { } params := ports.CreateRouteParams{ - Name: req.Name, - Pattern: req.PathPrefix, - Target: req.TargetURL, - Methods: req.Methods, - StripPrefix: req.StripPrefix, - RateLimit: req.RateLimit, - Priority: req.Priority, + Name: req.Name, + Pattern: req.PathPrefix, + Target: req.TargetURL, + Methods: req.Methods, + StripPrefix: req.StripPrefix, + RateLimit: req.RateLimit, + DialTimeout: req.DialTimeout, + ResponseHeaderTimeout: req.ResponseHeaderTimeout, + IdleConnTimeout: req.IdleConnTimeout, + TLSSkipVerify: req.TLSSkipVerify, + RequireTLS: req.RequireTLS, + AllowedCIDRs: req.AllowedCIDRs, + BlockedCIDRs: req.BlockedCIDRs, + MaxBodySize: req.MaxBodySize, + Priority: req.Priority, } route, err := h.svc.CreateRoute(c.Request.Context(), params) @@ -125,12 +147,25 @@ func (h *GatewayHandler) Proxy(c *gin.Context) { path = "/" + path } - proxy, params, ok := h.svc.GetProxy(c.Request.Method, path) + proxy, route, params, ok := h.svc.GetProxy(c.Request.Method, path) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "No route found for " + path}) return } + // Apply IP allowlist/denylist (nil route means no route-specific rules apply) + if route != nil && !h.checkCIDR(c, route) { + return + } + + // Apply request size limit + if route != nil && route.MaxBodySize > 0 { + c.Request.Body = &limitedReader{ + ReadCloser: c.Request.Body, + limit: route.MaxBodySize, + } + } + // Inject parameters into request context for downstream services if needed if len(params) > 0 { for k, v := range params { @@ -138,5 +173,90 @@ func (h *GatewayHandler) Proxy(c *gin.Context) { } } + // Inject trace headers + h.injectTraceHeaders(c) + proxy.ServeHTTP(c.Writer, c.Request) } + +func (h *GatewayHandler) injectTraceHeaders(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = uuid.New().String() + } + c.Request.Header.Set("X-Request-ID", requestID) + c.Header("X-Request-ID", requestID) + + // W3C TraceContext + traceID := generateTraceID() + spanID := generateSpanID() + c.Request.Header.Set("traceparent", fmt.Sprintf("00-%s-%s-01", traceID, spanID)) + c.Request.Header.Set("tracestate", "") +} + +func generateTraceID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func generateSpanID() string { + b := make([]byte, 8) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (h *GatewayHandler) checkCIDR(c *gin.Context, route *domain.GatewayRoute) bool { + clientIP := net.ParseIP(c.ClientIP()) + if clientIP == nil { + return true // Allow if we can't parse IP + } + + // Check blocked CIDRs first (takes precedence) + for _, cidrStr := range route.BlockedCIDRs { + if _, ipNet, err := net.ParseCIDR(cidrStr); err == nil && ipNet.Contains(clientIP) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return false + } + } + + // If allowlist is non-empty, only allow matched IPs + if len(route.AllowedCIDRs) > 0 { + allowed := false + for _, cidrStr := range route.AllowedCIDRs { + if _, ipNet, err := net.ParseCIDR(cidrStr); err == nil && ipNet.Contains(clientIP) { + allowed = true + break + } + } + if !allowed { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return false + } + } + + return true +} + +// limitedReader wraps an io.ReadCloser and enforces a byte limit. +type limitedReader struct { + io.ReadCloser + limit int64 + read int64 +} + +func (l *limitedReader) Read(p []byte) (n int, err error) { + if l.read >= l.limit { + return 0, io.EOF + } + toRead := l.limit - l.read + if int64(len(p)) > toRead { + p = p[:toRead] + } + n, err = l.ReadCloser.Read(p) + l.read += int64(n) + if l.read >= l.limit && err == nil { + err = io.EOF + } + return +} diff --git a/internal/handlers/gateway_handler_test.go b/internal/handlers/gateway_handler_test.go index 2f51b41db..3156ba467 100644 --- a/internal/handlers/gateway_handler_test.go +++ b/internal/handlers/gateway_handler_test.go @@ -49,21 +49,21 @@ func (m *mockGatewayService) CreateRoute(ctx context.Context, params ports.Creat return r0, args.Error(1) } -func (m *mockGatewayService) GetProxy(method, path string) (*httputil.ReverseProxy, map[string]string, bool) { +func (m *mockGatewayService) GetProxy(method, path string) (*httputil.ReverseProxy, *domain.GatewayRoute, map[string]string, bool) { args := m.Called(method, path) if args.Get(0) == nil { - return nil, nil, args.Bool(2) + return nil, nil, nil, args.Bool(3) + } + var route *domain.GatewayRoute + if r := args.Get(1); r != nil { + route = r.(*domain.GatewayRoute) } var params map[string]string - if p := args.Get(1); p != nil { - var ok bool - params, ok = p.(map[string]string) - if !ok { - params = nil - } + if p := args.Get(2); p != nil { + params = p.(map[string]string) } r0, _ := args.Get(0).(*httputil.ReverseProxy) - return r0, params, args.Bool(2) + return r0, route, params, args.Bool(3) } func (m *mockGatewayService) ListRoutes(ctx context.Context) ([]*domain.GatewayRoute, error) { @@ -168,7 +168,7 @@ func TestGatewayHandlerProxyNotFound(t *testing.T) { r.Any(gwProxyPath, handler.Proxy) - svc.On("GetProxy", "GET", "/unknown").Return(nil, nil, false) + svc.On("GetProxy", "GET", "/unknown").Return(nil, nil, nil, false) req, err := http.NewRequest(http.MethodGet, "/gw/unknown", nil) require.NoError(t, err) @@ -200,7 +200,7 @@ func TestGatewayHandlerProxySuccess(t *testing.T) { // Gateway Handler implementation: c.Request.URL.Path = c.Param("proxy")? or just calls ServeHTTP. // If GatewayHandler calls `proxy.ServeHTTP(w, c.Request)`, the request path "/gw/api" is sent to target. // Test server expects any path. - svc.On("GetProxy", "GET", "/api").Return(proxy, map[string]string{}, true) + svc.On("GetProxy", "GET", "/api").Return(proxy, (*domain.GatewayRoute)(nil), map[string]string{}, true) req, err := http.NewRequest(http.MethodGet, gwAPITestPath, nil) require.NoError(t, err) @@ -223,7 +223,7 @@ func TestGatewayHandlerProxyWithoutSlash(t *testing.T) { defer ts.Close() targetURL, _ := url.Parse(ts.URL) - svc.On("GetProxy", "GET", "/api").Return(httputil.NewSingleHostReverseProxy(targetURL), map[string]string{}, true) + svc.On("GetProxy", "GET", "/api").Return(httputil.NewSingleHostReverseProxy(targetURL), (*domain.GatewayRoute)(nil), map[string]string{}, true) req, err := http.NewRequest(http.MethodGet, gwAPITestPath, nil) require.NoError(t, err) @@ -246,7 +246,7 @@ func TestGatewayHandlerProxyWithSlash(t *testing.T) { defer ts.Close() targetURL, _ := url.Parse(ts.URL) - svc.On("GetProxy", "GET", "//api").Return(httputil.NewSingleHostReverseProxy(targetURL), map[string]string{}, true) + svc.On("GetProxy", "GET", "//api").Return(httputil.NewSingleHostReverseProxy(targetURL), (*domain.GatewayRoute)(nil), map[string]string{}, true) req, err := http.NewRequest(http.MethodGet, "/gw//api", nil) require.NoError(t, err) @@ -313,7 +313,7 @@ func TestGatewayHandlerProxyParamWithoutSlash(t *testing.T) { targetURL, _ := url.Parse(ts.URL) // Expect GetProxy to be called with "/api" (slash added) - mockSvc.On("GetProxy", "GET", "/api").Return(httputil.NewSingleHostReverseProxy(targetURL), map[string]string{}, true) + mockSvc.On("GetProxy", "GET", "/api").Return(httputil.NewSingleHostReverseProxy(targetURL), (*domain.GatewayRoute)(nil), map[string]string{}, true) handler.Proxy(c) From 0dcac66af665dfec66ae270194d6c5a6de8afc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 15:09:42 +0300 Subject: [PATCH 3/6] Add per-route rate limiting to IPRateLimiter - Added routes map for per-route limiter tracking - Added GetRouteLimiter method for route-specific rate limiting - Updated cleanupLoop to also clear routes map --- pkg/ratelimit/limiter.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index 982a56043..cbbc0f7ba 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -8,12 +8,14 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "golang.org/x/time/rate" ) // IPRateLimiter manages rate limiters for different IPs/clients type IPRateLimiter struct { ips map[string]*rate.Limiter + routes map[uuid.UUID]map[string]*rate.Limiter // per-route limiters mu sync.RWMutex rate rate.Limit burst int @@ -24,6 +26,7 @@ type IPRateLimiter struct { func NewIPRateLimiter(r rate.Limit, b int, logger *slog.Logger) *IPRateLimiter { i := &IPRateLimiter{ ips: make(map[string]*rate.Limiter), + routes: make(map[uuid.UUID]map[string]*rate.Limiter), rate: r, burst: b, logger: logger, @@ -49,6 +52,25 @@ func (i *IPRateLimiter) GetLimiter(key string) *rate.Limiter { return limiter } +// GetRouteLimiter returns a rate limiter for a specific route and client key. +// This enables per-route rate limiting while maintaining per-client tracking. +func (i *IPRateLimiter) GetRouteLimiter(routeID uuid.UUID, key string) *rate.Limiter { + i.mu.Lock() + defer i.mu.Unlock() + + if i.routes[routeID] == nil { + i.routes[routeID] = make(map[string]*rate.Limiter) + } + + limiter, exists := i.routes[routeID][key] + if !exists { + limiter = rate.NewLimiter(i.rate, i.burst) + i.routes[routeID][key] = limiter + } + + return limiter +} + // cleanupLoop removes old entries (rudimentary GC) func (i *IPRateLimiter) cleanupLoop() { for { @@ -57,6 +79,7 @@ func (i *IPRateLimiter) cleanupLoop() { // Start fresh every cleanup cycle for simplicity // A production robust implementation would track last access time i.ips = make(map[string]*rate.Limiter) + i.routes = make(map[uuid.UUID]map[string]*rate.Limiter) i.mu.Unlock() } } From 17e301ff9fa4362314fbf13f073437437f8e3829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 16:42:01 +0300 Subject: [PATCH 4/6] Fix minor issues: logger nil check, error comment, trailing newlines - Add nil check before h.logger.Warn() to prevent panic - Add comment explaining error shadowing in limitedReader.Read - Fix missing trailing newlines in test files --- internal/api/setup/router.go | 2 +- internal/handlers/gateway_handler.go | 47 ++++++++++++--- .../handlers/gateway_handler_cidr_test.go | 58 +++++++++++++++++++ internal/handlers/gateway_handler_test.go | 4 +- .../handlers/gateway_handler_trace_test.go | 38 ++++++++++++ 5 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 internal/handlers/gateway_handler_cidr_test.go create mode 100644 internal/handlers/gateway_handler_trace_test.go diff --git a/internal/api/setup/router.go b/internal/api/setup/router.go index 86552be61..423650e43 100644 --- a/internal/api/setup/router.go +++ b/internal/api/setup/router.go @@ -106,7 +106,7 @@ func InitHandlers(svcs *Services, cfg *platform.Config, logger *slog.Logger) *Ha Queue: httphandlers.NewQueueHandler(svcs.Queue), Notify: httphandlers.NewNotifyHandler(svcs.Notify), Cron: httphandlers.NewCronHandler(svcs.Cron), - Gateway: httphandlers.NewGatewayHandler(svcs.Gateway), + Gateway: httphandlers.NewGatewayHandler(svcs.Gateway, logger), Container: httphandlers.NewContainerHandler(svcs.Container), Pipeline: httphandlers.NewPipelineHandler(svcs.Pipeline), Health: httphandlers.NewHealthHandler(svcs.Health), diff --git a/internal/handlers/gateway_handler.go b/internal/handlers/gateway_handler.go index d9b51c8fa..c5e78254e 100644 --- a/internal/handlers/gateway_handler.go +++ b/internal/handlers/gateway_handler.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "log/slog" "net" "net/http" "strings" @@ -39,12 +40,13 @@ type CreateRouteRequest struct { // GatewayHandler handles API gateway HTTP endpoints. type GatewayHandler struct { - svc ports.GatewayService + svc ports.GatewayService + logger *slog.Logger } // NewGatewayHandler constructs a GatewayHandler. -func NewGatewayHandler(svc ports.GatewayService) *GatewayHandler { - return &GatewayHandler{svc: svc} +func NewGatewayHandler(svc ports.GatewayService, logger *slog.Logger) *GatewayHandler { + return &GatewayHandler{svc: svc, logger: logger} } // CreateRoute establishes a new ingress mapping @@ -70,6 +72,12 @@ func (h *GatewayHandler) CreateRoute(c *gin.Context) { req.RateLimit = 100 } + // Validate TLS settings + if req.RequireTLS && req.TLSSkipVerify { + httputil.Error(c, errors.New(errors.InvalidInput, "cannot set both require_tls and tls_skip_verify")) + return + } + params := ports.CreateRouteParams{ Name: req.Name, Pattern: req.PathPrefix, @@ -196,13 +204,18 @@ func (h *GatewayHandler) injectTraceHeaders(c *gin.Context) { func generateTraceID() string { b := make([]byte, 16) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + // crypto/rand.Read rarely fails, but handle it gracefully + return uuid.New().String() + } return hex.EncodeToString(b) } func generateSpanID() string { b := make([]byte, 8) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return uuid.New().String()[:16] + } return hex.EncodeToString(b) } @@ -214,7 +227,14 @@ func (h *GatewayHandler) checkCIDR(c *gin.Context, route *domain.GatewayRoute) b // Check blocked CIDRs first (takes precedence) for _, cidrStr := range route.BlockedCIDRs { - if _, ipNet, err := net.ParseCIDR(cidrStr); err == nil && ipNet.Contains(clientIP) { + _, ipNet, err := net.ParseCIDR(cidrStr) + if err != nil { + if h.logger != nil { + h.logger.Warn("invalid blocked CIDR", "cidr", cidrStr, "error", err) + } + continue + } + if ipNet.Contains(clientIP) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) return false } @@ -224,7 +244,14 @@ func (h *GatewayHandler) checkCIDR(c *gin.Context, route *domain.GatewayRoute) b if len(route.AllowedCIDRs) > 0 { allowed := false for _, cidrStr := range route.AllowedCIDRs { - if _, ipNet, err := net.ParseCIDR(cidrStr); err == nil && ipNet.Contains(clientIP) { + _, ipNet, err := net.ParseCIDR(cidrStr) + if err != nil { + if h.logger != nil { + h.logger.Warn("invalid allowed CIDR", "cidr", cidrStr, "error", err) + } + continue + } + if ipNet.Contains(clientIP) { allowed = true break } @@ -245,6 +272,8 @@ type limitedReader struct { read int64 } +// Read enforces the byte limit. When the limit is reached, io.EOF is returned +// even if the underlying reader returned an error (error shadowing for limit enforcement). func (l *limitedReader) Read(p []byte) (n int, err error) { if l.read >= l.limit { return 0, io.EOF @@ -260,3 +289,7 @@ func (l *limitedReader) Read(p []byte) (n int, err error) { } return } + +func (l *limitedReader) Close() error { + return l.ReadCloser.Close() +} diff --git a/internal/handlers/gateway_handler_cidr_test.go b/internal/handlers/gateway_handler_cidr_test.go new file mode 100644 index 000000000..90bb1e9a3 --- /dev/null +++ b/internal/handlers/gateway_handler_cidr_test.go @@ -0,0 +1,58 @@ +package httphandlers + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/poyrazk/thecloud/internal/core/domain" + "github.com/stretchr/testify/assert" +) + +func TestCheckCIDR_NoBlockedOrAllowed(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + handler := &GatewayHandler{logger: nil} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.RemoteAddr = "10.0.0.1:12345" + + route := &domain.GatewayRoute{ + BlockedCIDRs: []string{}, + AllowedCIDRs: []string{}, + } + + result := handler.checkCIDR(c, route) + assert.True(t, result, "no restrictions should allow all") +} + +func TestCheckCIDR_EmptyBlockedAndAllowed(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + handler := &GatewayHandler{logger: nil} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.RemoteAddr = "10.0.0.1:12345" + + route := &domain.GatewayRoute{} + + result := handler.checkCIDR(c, route) + assert.True(t, result, "empty CIDR lists should allow all") +} + +func TestCheckCIDR_InvalidIP(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + handler := &GatewayHandler{logger: nil} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/", nil) + // Leave RemoteAddr empty to test invalid IP handling + + route := &domain.GatewayRoute{} + + result := handler.checkCIDR(c, route) + assert.True(t, result, "invalid IP should be allowed (fail open)") +} diff --git a/internal/handlers/gateway_handler_test.go b/internal/handlers/gateway_handler_test.go index 3156ba467..f55c22fe8 100644 --- a/internal/handlers/gateway_handler_test.go +++ b/internal/handlers/gateway_handler_test.go @@ -88,7 +88,7 @@ func (m *mockGatewayService) RefreshRoutes(ctx context.Context) error { func setupGatewayHandlerTest(_ *testing.T) (*mockGatewayService, *GatewayHandler, *gin.Engine) { gin.SetMode(gin.TestMode) svc := new(mockGatewayService) - handler := NewGatewayHandler(svc) + handler := NewGatewayHandler(svc, nil) r := gin.New() return svc, handler, r } @@ -296,7 +296,7 @@ func TestGatewayHandlerListError(t *testing.T) { func TestGatewayHandlerProxyParamWithoutSlash(t *testing.T) { t.Parallel() mockSvc := new(mockGatewayService) - handler := NewGatewayHandler(mockSvc) + handler := NewGatewayHandler(mockSvc, nil) gin.SetMode(gin.TestMode) // Manually create context to pass parameter without slash diff --git a/internal/handlers/gateway_handler_trace_test.go b/internal/handlers/gateway_handler_trace_test.go new file mode 100644 index 000000000..9c4ad6774 --- /dev/null +++ b/internal/handlers/gateway_handler_trace_test.go @@ -0,0 +1,38 @@ +package httphandlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateTraceID(t *testing.T) { + t.Parallel() + + id := generateTraceID() + assert.Len(t, id, 32, "trace ID should be 32 hex characters (16 bytes)") +} + +func TestGenerateSpanID(t *testing.T) { + t.Parallel() + + id := generateSpanID() + assert.Len(t, id, 16, "span ID should be 16 hex characters (8 bytes)") +} + +func TestGenerateTraceID_Uniqueness(t *testing.T) { + t.Parallel() + + // Generate two IDs and ensure they're different + id1 := generateTraceID() + id2 := generateTraceID() + assert.NotEqual(t, id1, id2, "consecutive trace IDs should be unique") +} + +func TestGenerateSpanID_Uniqueness(t *testing.T) { + t.Parallel() + + id1 := generateSpanID() + id2 := generateSpanID() + assert.NotEqual(t, id1, id2, "consecutive span IDs should be unique") +} From 65af1ad9e594b7583d81b16de8828f32d6989656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 19:53:00 +0300 Subject: [PATCH 5/6] docs: update swagger docs for API gateway Phase 1 features Add OpenAPI definitions for new gateway route fields: - Timeout settings: dial_timeout, response_header_timeout, idle_conn_timeout - TLS settings: tls_skip_verify, require_tls - Security: allowed_cidrs, blocked_cidrs - Request limits: max_body_size --- docs/swagger/docs.go | 68 +++++++++++++++++++++++++++++++++++++++ docs/swagger/swagger.json | 68 +++++++++++++++++++++++++++++++++++++++ docs/swagger/swagger.yaml | 48 +++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 49a8d4108..5f4e4ed2f 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -8680,12 +8680,38 @@ const docTemplate = `{ "domain.GatewayRoute": { "type": "object", "properties": { + "allowed_cidrs": { + "description": "IPs allowed to access (empty = all)", + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_cidrs": { + "description": "IPs blocked from access", + "type": "array", + "items": { + "type": "string" + } + }, "created_at": { "type": "string" }, + "dial_timeout": { + "description": "TCP dial timeout in milliseconds", + "type": "integer" + }, "id": { "type": "string" }, + "idle_conn_timeout": { + "description": "Idle connection timeout in milliseconds", + "type": "integer" + }, + "max_body_size": { + "description": "Max request body size in bytes", + "type": "integer" + }, "methods": { "description": "New: HTTP methods to match (empty = all)", "type": "array", @@ -8723,6 +8749,14 @@ const docTemplate = `{ "description": "Maximum allowed requests per second per IP", "type": "integer" }, + "require_tls": { + "description": "Force HTTPS for backend", + "type": "boolean" + }, + "response_header_timeout": { + "description": "Time to receive headers in milliseconds", + "type": "integer" + }, "strip_prefix": { "description": "If true, removes path_prefix from request before forwarding", "type": "boolean" @@ -8734,6 +8768,10 @@ const docTemplate = `{ "tenant_id": { "type": "string" }, + "tls_skip_verify": { + "description": "Skip TLS verification for backend", + "type": "boolean" + }, "updated_at": { "type": "string" }, @@ -11036,6 +11074,27 @@ const docTemplate = `{ "target_url" ], "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dial_timeout": { + "type": "integer" + }, + "idle_conn_timeout": { + "type": "integer" + }, + "max_body_size": { + "type": "integer" + }, "methods": { "type": "array", "items": { @@ -11054,11 +11113,20 @@ const docTemplate = `{ "rate_limit": { "type": "integer" }, + "require_tls": { + "type": "boolean" + }, + "response_header_timeout": { + "type": "integer" + }, "strip_prefix": { "type": "boolean" }, "target_url": { "type": "string" + }, + "tls_skip_verify": { + "type": "boolean" } } }, diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index a4e653353..10a8e6945 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -8672,12 +8672,38 @@ "domain.GatewayRoute": { "type": "object", "properties": { + "allowed_cidrs": { + "description": "IPs allowed to access (empty = all)", + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_cidrs": { + "description": "IPs blocked from access", + "type": "array", + "items": { + "type": "string" + } + }, "created_at": { "type": "string" }, + "dial_timeout": { + "description": "TCP dial timeout in milliseconds", + "type": "integer" + }, "id": { "type": "string" }, + "idle_conn_timeout": { + "description": "Idle connection timeout in milliseconds", + "type": "integer" + }, + "max_body_size": { + "description": "Max request body size in bytes", + "type": "integer" + }, "methods": { "description": "New: HTTP methods to match (empty = all)", "type": "array", @@ -8715,6 +8741,14 @@ "description": "Maximum allowed requests per second per IP", "type": "integer" }, + "require_tls": { + "description": "Force HTTPS for backend", + "type": "boolean" + }, + "response_header_timeout": { + "description": "Time to receive headers in milliseconds", + "type": "integer" + }, "strip_prefix": { "description": "If true, removes path_prefix from request before forwarding", "type": "boolean" @@ -8726,6 +8760,10 @@ "tenant_id": { "type": "string" }, + "tls_skip_verify": { + "description": "Skip TLS verification for backend", + "type": "boolean" + }, "updated_at": { "type": "string" }, @@ -11028,6 +11066,27 @@ "target_url" ], "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dial_timeout": { + "type": "integer" + }, + "idle_conn_timeout": { + "type": "integer" + }, + "max_body_size": { + "type": "integer" + }, "methods": { "type": "array", "items": { @@ -11046,11 +11105,20 @@ "rate_limit": { "type": "integer" }, + "require_tls": { + "type": "boolean" + }, + "response_header_timeout": { + "type": "integer" + }, "strip_prefix": { "type": "boolean" }, "target_url": { "type": "string" + }, + "tls_skip_verify": { + "type": "boolean" } } }, diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 5dba3664b..4b903592e 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -401,10 +401,29 @@ definitions: type: object domain.GatewayRoute: properties: + allowed_cidrs: + description: IPs allowed to access (empty = all) + items: + type: string + type: array + blocked_cidrs: + description: IPs blocked from access + items: + type: string + type: array created_at: type: string + dial_timeout: + description: TCP dial timeout in milliseconds + type: integer id: type: string + idle_conn_timeout: + description: Idle connection timeout in milliseconds + type: integer + max_body_size: + description: Max request body size in bytes + type: integer methods: description: 'New: HTTP methods to match (empty = all)' items: @@ -432,6 +451,12 @@ definitions: rate_limit: description: Maximum allowed requests per second per IP type: integer + require_tls: + description: Force HTTPS for backend + type: boolean + response_header_timeout: + description: Time to receive headers in milliseconds + type: integer strip_prefix: description: If true, removes path_prefix from request before forwarding type: boolean @@ -440,6 +465,9 @@ definitions: type: string tenant_id: type: string + tls_skip_verify: + description: Skip TLS verification for backend + type: boolean updated_at: type: string user_id: @@ -2099,6 +2127,20 @@ definitions: type: object httphandlers.CreateRouteRequest: properties: + allowed_cidrs: + items: + type: string + type: array + blocked_cidrs: + items: + type: string + type: array + dial_timeout: + type: integer + idle_conn_timeout: + type: integer + max_body_size: + type: integer methods: items: type: string @@ -2111,10 +2153,16 @@ definitions: type: integer rate_limit: type: integer + require_tls: + type: boolean + response_header_timeout: + type: integer strip_prefix: type: boolean target_url: type: string + tls_skip_verify: + type: boolean required: - name - path_prefix From 183514c34abecf04bd73a98cf52df1ab9fbf001f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 2 May 2026 22:11:13 +0300 Subject: [PATCH 6/6] fix(gateway): suppress gosec G402 for intentional TLSSkipVerify The tls_skip_verify feature intentionally allows users to disable TLS verification for development/testing backends. gosec G402 flags this pattern as a security risk, but it is the intended behavior for this feature, not a vulnerability. --- internal/core/services/gateway.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/services/gateway.go b/internal/core/services/gateway.go index 1c4d0e24c..775175487 100644 --- a/internal/core/services/gateway.go +++ b/internal/core/services/gateway.go @@ -237,7 +237,7 @@ func (s *GatewayService) createReverseProxy(route *domain.GatewayRoute) (*httput func (s *GatewayService) buildTLSConfig(route *domain.GatewayRoute) *tls.Config { cfg := &tls.Config{ - InsecureSkipVerify: route.TLSSkipVerify, + InsecureSkipVerify: route.TLSSkipVerify, //nolint:gosec // User-controlled option for development/testing } if route.RequireTLS { cfg.MinVersion = tls.VersionTLS12