feat: add proxy backend errors interceptor

This commit is contained in:
Jonas Kaninda
2024-10-29 09:39:31 +01:00
parent c405882943
commit 9f14c2fa08
10 changed files with 154 additions and 29 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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"