diff --git a/README.md b/README.md index f5c43eb..098a1e4 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ ``` Goma Gateway is a lightweight API Gateway and Reverse Proxy. +Simple, easy to use, and configure. + [![Build](https://github.com/jkaninda/goma-gateway/actions/workflows/release.yml/badge.svg)](https://github.com/jkaninda/goma-gateway/actions/workflows/release.yml) -[![Go Report](https://goreportcard.com/badge/github.com/jkaninda/goma-gateway)](https://goreportcard.com/report/github.com/jkaninda/goma-gateway) +[![Go Report Card](https://goreportcard.com/badge/github.com/jkaninda/goma-gateway)](https://goreportcard.com/report/github.com/jkaninda/goma-gateway) [![Go Reference](https://pkg.go.dev/badge/github.com/jkaninda/goma-gateway.svg)](https://pkg.go.dev/github.com/jkaninda/goma-gateway) ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/jkaninda/goma-gateway?style=flat-square) @@ -25,18 +27,20 @@ Goma Gateway is a lightweight API Gateway and Reverse Proxy. - [x] Reverse proxy - [x] API Gateway +- [x] Domain/host based request routing +- [x] Multi domain request routing - [x] Cors -- [ ] Add Load balancing feature - [ ] Support TLS +- [x] Backend errors interceptor - [x] Authentication middleware - [x] JWT `HTTP Bearer Token` - [x] Basic-Auth - - [ ] OAuth2 + - [ ] OAuth - [x] Implement rate limiting - [x] In-Memory Token Bucket based - [x] In-Memory client IP based - [ ] Distributed Rate Limiting for Token based across multiple instances using Redis - - [ ] Distributed Rate Limiting for In-Memory client IP based across multiple instances using Redis + - [ ] Distributed Rate Limiting for In-Memory client IP based across multiple instances using Redis ## Usage @@ -124,6 +128,11 @@ gateway: disableRouteHealthCheckError: false # Disable display routes on start disableDisplayRouteOnStart: false + # interceptErrors intercepts backend errors based on defined the status codes + interceptErrors: + # - 405 + # - 500 + # - 400 # Proxy Global HTTP Cors cors: # Global routes cors for all routes @@ -218,15 +227,16 @@ gateway: middlewares: # Enable Basic auth authorization based - name: local-auth-basic - # Authentication types | jwt, basic, auth0 - type: basic + # Authentication types | jwtAuth, basicAuth, OAuth + type: basicAuth rule: username: admin password: admin #Enables JWT authorization based on the result of a request and continues the request. - name: google-auth - # Authentication types | jwt, basic, auth0 - type: jwt + # Authentication types | jwtAuth, basicAuth, auth0 + # jwt authorization based on the result of backend's response and continue the request when the client is authorized + type: jwtAuth rule: url: https://www.googleapis.com/auth/userinfo.email # Required headers, if not present in the request, the proxy will return 403 diff --git a/goma.yml b/goma.yml index 97afa89..5cf493f 100644 --- a/goma.yml +++ b/goma.yml @@ -18,6 +18,11 @@ gateway: disableRouteHealthCheckError: false # Disable display routes on start disableDisplayRouteOnStart: false + # interceptErrors intercepts backend errors based on defined the status codes + interceptErrors: + # - 405 + # - 500 + # - 400 # Proxy Global HTTP Cors cors: # Global routes cors for all routes @@ -112,15 +117,16 @@ gateway: middlewares: # Enable Basic auth authorization based - name: local-auth-basic - # Authentication types | jwt, basic, auth0 - type: basic + # Authentication types | jwtAuth, basicAuth, auth0 + type: basicAuth rule: username: admin password: admin #Enables JWT authorization based on the result of a request and continues the request. - name: google-auth - # Authentication types | jwt, basic, auth0 - type: jwt + # Authentication types | jwtAuth, basicAuth, OAuth + # jwt authorization based on the result of backend's response and continue the request when the client is authorized + type: jwtAuth rule: url: https://www.googleapis.com/auth/userinfo.email # Required headers, if not present in the request, the proxy will return 403 diff --git a/internal/logger/logger.go b/internal/logger/logger.go index bdb15dd..26abe18 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -77,9 +77,9 @@ func Debug(msg string, args ...interface{}) { log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout"))) formattedMessage := fmt.Sprintf(msg, args...) if len(args) == 0 { - log.Printf("INFO: %s\n", msg) + log.Printf("DUBUG: %s\n", msg) } else { - log.Printf("INFO: %s\n", formattedMessage) + log.Printf("DUBUG: %s\n", formattedMessage) } } func getStd(out string) *os.File { diff --git a/pkg/config.go b/pkg/config.go index 5c592c9..28db467 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -125,6 +125,10 @@ type Route struct { HealthCheck string `yaml:"healthCheck"` // Blocklist Defines route blacklist Blocklist []string `yaml:"blocklist"` + // InterceptErrors intercepts backend errors based on the status codes + // + // Eg: [ 403, 405, 500 ] + InterceptErrors []int `yaml:"interceptErrors"` // Middlewares Defines route middleware from Middleware names Middlewares []RouteMiddleware `yaml:"middlewares"` } @@ -147,7 +151,8 @@ type Gateway struct { ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"` DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"` //Disable dispelling routes on start - DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"` + DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"` + InterceptErrors []int `yaml:"interceptErrors"` // Cors contains the proxy global cors Cors Cors `yaml:"cors"` // Routes defines the proxy routes @@ -351,7 +356,7 @@ func ToJWTRuler(input interface{}) (JWTRuler, error) { return JWTRuler{}, fmt.Errorf("error parsing yaml: %v", err) } if jWTRuler.URL == "" { - return JWTRuler{}, fmt.Errorf("error parsing yaml: empty url in jwt auth middleware") + return JWTRuler{}, fmt.Errorf("error parsing yaml: empty url in %s auth middleware", jwtAuth) } return *jWTRuler, nil @@ -369,7 +374,7 @@ func ToBasicAuth(input interface{}) (BasicRule, error) { return BasicRule{}, fmt.Errorf("error parsing yaml: %v", err) } if basicAuth.Username == "" || basicAuth.Password == "" { - return BasicRule{}, fmt.Errorf("error parsing yaml: empty username/password in basic auth middleware") + return BasicRule{}, fmt.Errorf("error parsing yaml: empty username/password in %s middleware", basicAuth) } return *basicAuth, nil diff --git a/pkg/middleware/error-interceptor.go b/pkg/middleware/error-interceptor.go new file mode 100644 index 0000000..8383487 --- /dev/null +++ b/pkg/middleware/error-interceptor.go @@ -0,0 +1,94 @@ +package middleware + +/* + * Copyright 2024 Jonas Kaninda + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import ( + "bytes" + "encoding/json" + "github.com/jkaninda/goma-gateway/internal/logger" + "io" + "net/http" +) + +// InterceptErrors contains backend status code errors to intercept +type InterceptErrors struct { + Errors []int +} + +// responseRecorder intercepts the response body and status code +type responseRecorder struct { + http.ResponseWriter + statusCode int + body *bytes.Buffer +} + +func newResponseRecorder(w http.ResponseWriter) *responseRecorder { + return &responseRecorder{ + ResponseWriter: w, + statusCode: http.StatusOK, + body: &bytes.Buffer{}, + } +} + +func (rec *responseRecorder) WriteHeader(code int) { + rec.statusCode = code +} + +func (rec *responseRecorder) Write(data []byte) (int, error) { + return rec.body.Write(data) +} + +// ErrorInterceptor Middleware intercepts backend errors +func (intercept InterceptErrors) ErrorInterceptor(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec := newResponseRecorder(w) + next.ServeHTTP(rec, r) + if canIntercept(rec.statusCode, intercept.Errors) { + logger.Debug("Backend error intercepted") + logger.Debug("An error occurred in the backend, %d", rec.statusCode) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(rec.statusCode) + err := json.NewEncoder(w).Encode(ProxyResponseError{ + Success: false, + Code: rec.statusCode, + Message: http.StatusText(rec.statusCode), + }) + if err != nil { + return + } + } else { + // No error: write buffered response to client + w.WriteHeader(rec.statusCode) + _, err := io.Copy(w, rec.body) + if err != nil { + return + } + + } + + }) +} +func canIntercept(code int, errors []int) bool { + for _, er := range errors { + if er == code { + return true + } + continue + + } + return false +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 30bf8eb..e3c2116 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -94,7 +94,9 @@ type AuthBasic struct { Params map[string]string } -// AuthMiddleware function, which will be called for each request +// AuthMiddleware authenticate the client using JWT +// +// authorization based on the result of backend's response and continue the request when the client is authorized func (amw AuthJWT) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for _, header := range amw.RequiredHeaders { diff --git a/pkg/middleware/rate_limiter.go b/pkg/middleware/rate-limiter.go similarity index 96% rename from pkg/middleware/rate_limiter.go rename to pkg/middleware/rate-limiter.go index 914b6bc..d9df3bf 100644 --- a/pkg/middleware/rate_limiter.go +++ b/pkg/middleware/rate-limiter.go @@ -66,7 +66,7 @@ func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc { rl.mu.Unlock() if client.RequestCount > rl.Requests { - logger.Warn("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent()) + logger.Debug("Too many request from IP: %s %s %s", clientID, r.URL, r.UserAgent()) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) err := json.NewEncoder(w).Encode(ProxyResponseError{ diff --git a/pkg/proxy.go b/pkg/proxy.go index 97a91b9..0bd0c78 100644 --- a/pkg/proxy.go +++ b/pkg/proxy.go @@ -47,7 +47,7 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc { //Update Origin Cors Headers for _, origin := range proxyRoute.cors.Origins { if origin == r.Header.Get("Origin") { - w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set(accessControlAllowOrigin, origin) } } @@ -90,12 +90,6 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc { r.URL.Path = strings.Replace(r.URL.Path, fmt.Sprintf("%s/", proxyRoute.path), proxyRoute.rewrite, 1) } } - proxy.ModifyResponse = func(response *http.Response) error { - if response.StatusCode < 200 || response.StatusCode >= 300 { - //TODO || Add override backend errors | user can enable or disable it - } - return nil - } // Custom error handler for proxy errors proxy.ErrorHandler = ProxyErrorHandler proxy.ServeHTTP(w, r) diff --git a/pkg/route.go b/pkg/route.go index ae3d782..f2fc3a8 100644 --- a/pkg/route.go +++ b/pkg/route.go @@ -40,6 +40,8 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { // Add rate limit middleware to all routes, if defined r.Use(limiter.RateLimitMiddleware()) } + // Add the errorInterceptor middleware + //r.Use(middleware.ErrorInterceptor) for _, route := range gateway.Routes { if route.Path != "" { blM := middleware.BlockListMiddleware{ @@ -63,7 +65,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { } else { //Check Authentication middleware switch rMiddleware.Type { - case "basic": + case basicAuth, "basic": basicAuth, err := ToBasicAuth(rMiddleware.Rule) if err != nil { logger.Error("Error: %s", err.Error()) @@ -80,7 +82,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { secureRouter.PathPrefix("/").Handler(proxyRoute.ProxyHandler()) // Proxy handler secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler } - case "jwt": + case jwtAuth, "jwt": jwt, err := ToJWTRuler(rMiddleware.Rule) if err != nil { logger.Error("Error: %s", err.Error()) @@ -98,6 +100,9 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler } + case OAuth, "auth0": + logger.Error("OAuth is not yet implemented") + logger.Info("Auth middleware ignored") default: logger.Error("Unknown middleware type %s", rMiddleware.Type) @@ -129,11 +134,16 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router { } } else { logger.Error("Error, path is empty in route %s", route.Name) - logger.Info("Route path ignored: %s", route.Path) + logger.Debug("Route path ignored: %s", route.Path) } } // Apply global Cors middlewares r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware + // Apply errorInterceptor middleware + interceptErrors := middleware.InterceptErrors{ + Errors: gateway.InterceptErrors, + } + r.Use(interceptErrors.ErrorInterceptor) return r } diff --git a/pkg/var.go b/pkg/var.go index 4850aef..5a75b09 100644 --- a/pkg/var.go +++ b/pkg/var.go @@ -1,3 +1,7 @@ package pkg const ConfigFile = "/config/goma.yml" +const accessControlAllowOrigin = "Access-Control-Allow-Origin" +const basicAuth = "basicAuth" +const jwtAuth = "jwtAuth" +const OAuth = "OAuth"