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. 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) [![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) [![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) ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/jkaninda/goma-gateway?style=flat-square)
@@ -25,13 +27,15 @@ Goma Gateway is a lightweight API Gateway and Reverse Proxy.
- [x] Reverse proxy - [x] Reverse proxy
- [x] API Gateway - [x] API Gateway
- [x] Domain/host based request routing
- [x] Multi domain request routing
- [x] Cors - [x] Cors
- [ ] Add Load balancing feature
- [ ] Support TLS - [ ] Support TLS
- [x] Backend errors interceptor
- [x] Authentication middleware - [x] Authentication middleware
- [x] JWT `HTTP Bearer Token` - [x] JWT `HTTP Bearer Token`
- [x] Basic-Auth - [x] Basic-Auth
- [ ] OAuth2 - [ ] OAuth
- [x] Implement rate limiting - [x] Implement rate limiting
- [x] In-Memory Token Bucket based - [x] In-Memory Token Bucket based
- [x] In-Memory client IP based - [x] In-Memory client IP based
@@ -124,6 +128,11 @@ gateway:
disableRouteHealthCheckError: false disableRouteHealthCheckError: false
# Disable display routes on start # Disable display routes on start
disableDisplayRouteOnStart: false disableDisplayRouteOnStart: false
# interceptErrors intercepts backend errors based on defined the status codes
interceptErrors:
# - 405
# - 500
# - 400
# Proxy Global HTTP Cors # Proxy Global HTTP Cors
cors: cors:
# Global routes cors for all routes # Global routes cors for all routes
@@ -218,15 +227,16 @@ gateway:
middlewares: middlewares:
# Enable Basic auth authorization based # Enable Basic auth authorization based
- name: local-auth-basic - name: local-auth-basic
# Authentication types | jwt, basic, auth0 # Authentication types | jwtAuth, basicAuth, OAuth
type: basic type: basicAuth
rule: rule:
username: admin username: admin
password: admin password: admin
#Enables JWT authorization based on the result of a request and continues the request. #Enables JWT authorization based on the result of a request and continues the request.
- name: google-auth - name: google-auth
# Authentication types | jwt, basic, auth0 # Authentication types | jwtAuth, basicAuth, auth0
type: jwt # jwt authorization based on the result of backend's response and continue the request when the client is authorized
type: jwtAuth
rule: rule:
url: https://www.googleapis.com/auth/userinfo.email url: https://www.googleapis.com/auth/userinfo.email
# Required headers, if not present in the request, the proxy will return 403 # Required headers, if not present in the request, the proxy will return 403

View File

@@ -18,6 +18,11 @@ gateway:
disableRouteHealthCheckError: false disableRouteHealthCheckError: false
# Disable display routes on start # Disable display routes on start
disableDisplayRouteOnStart: false disableDisplayRouteOnStart: false
# interceptErrors intercepts backend errors based on defined the status codes
interceptErrors:
# - 405
# - 500
# - 400
# Proxy Global HTTP Cors # Proxy Global HTTP Cors
cors: cors:
# Global routes cors for all routes # Global routes cors for all routes
@@ -112,15 +117,16 @@ gateway:
middlewares: middlewares:
# Enable Basic auth authorization based # Enable Basic auth authorization based
- name: local-auth-basic - name: local-auth-basic
# Authentication types | jwt, basic, auth0 # Authentication types | jwtAuth, basicAuth, auth0
type: basic type: basicAuth
rule: rule:
username: admin username: admin
password: admin password: admin
#Enables JWT authorization based on the result of a request and continues the request. #Enables JWT authorization based on the result of a request and continues the request.
- name: google-auth - name: google-auth
# Authentication types | jwt, basic, auth0 # Authentication types | jwtAuth, basicAuth, OAuth
type: jwt # jwt authorization based on the result of backend's response and continue the request when the client is authorized
type: jwtAuth
rule: rule:
url: https://www.googleapis.com/auth/userinfo.email url: https://www.googleapis.com/auth/userinfo.email
# Required headers, if not present in the request, the proxy will return 403 # 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"))) log.SetOutput(getStd(util.GetStringEnv("GOMA_ACCESS_LOG", "/dev/stdout")))
formattedMessage := fmt.Sprintf(msg, args...) formattedMessage := fmt.Sprintf(msg, args...)
if len(args) == 0 { if len(args) == 0 {
log.Printf("INFO: %s\n", msg) log.Printf("DUBUG: %s\n", msg)
} else { } else {
log.Printf("INFO: %s\n", formattedMessage) log.Printf("DUBUG: %s\n", formattedMessage)
} }
} }
func getStd(out string) *os.File { func getStd(out string) *os.File {

View File

@@ -125,6 +125,10 @@ type Route struct {
HealthCheck string `yaml:"healthCheck"` HealthCheck string `yaml:"healthCheck"`
// Blocklist Defines route blacklist // Blocklist Defines route blacklist
Blocklist []string `yaml:"blocklist"` 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 Defines route middleware from Middleware names
Middlewares []RouteMiddleware `yaml:"middlewares"` Middlewares []RouteMiddleware `yaml:"middlewares"`
} }
@@ -148,6 +152,7 @@ type Gateway struct {
DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"` DisableRouteHealthCheckError bool `yaml:"disableRouteHealthCheckError"`
//Disable dispelling routes on start //Disable dispelling routes on start
DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"` DisableDisplayRouteOnStart bool `yaml:"disableDisplayRouteOnStart"`
InterceptErrors []int `yaml:"interceptErrors"`
// Cors contains the proxy global cors // Cors contains the proxy global cors
Cors Cors `yaml:"cors"` Cors Cors `yaml:"cors"`
// Routes defines the proxy routes // Routes defines the proxy routes
@@ -351,7 +356,7 @@ func ToJWTRuler(input interface{}) (JWTRuler, error) {
return JWTRuler{}, fmt.Errorf("error parsing yaml: %v", err) return JWTRuler{}, fmt.Errorf("error parsing yaml: %v", err)
} }
if jWTRuler.URL == "" { 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 return *jWTRuler, nil
@@ -369,7 +374,7 @@ func ToBasicAuth(input interface{}) (BasicRule, error) {
return BasicRule{}, fmt.Errorf("error parsing yaml: %v", err) return BasicRule{}, fmt.Errorf("error parsing yaml: %v", err)
} }
if basicAuth.Username == "" || basicAuth.Password == "" { 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 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 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 { func (amw AuthJWT) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, header := range amw.RequiredHeaders { for _, header := range amw.RequiredHeaders {

View File

@@ -66,7 +66,7 @@ func (rl *RateLimiter) RateLimitMiddleware() mux.MiddlewareFunc {
rl.mu.Unlock() rl.mu.Unlock()
if client.RequestCount > rl.Requests { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests) w.WriteHeader(http.StatusTooManyRequests)
err := json.NewEncoder(w).Encode(ProxyResponseError{ err := json.NewEncoder(w).Encode(ProxyResponseError{

View File

@@ -47,7 +47,7 @@ func (proxyRoute ProxyRoute) ProxyHandler() http.HandlerFunc {
//Update Origin Cors Headers //Update Origin Cors Headers
for _, origin := range proxyRoute.cors.Origins { for _, origin := range proxyRoute.cors.Origins {
if origin == r.Header.Get("Origin") { 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) 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 // Custom error handler for proxy errors
proxy.ErrorHandler = ProxyErrorHandler proxy.ErrorHandler = ProxyErrorHandler
proxy.ServeHTTP(w, r) 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 // Add rate limit middleware to all routes, if defined
r.Use(limiter.RateLimitMiddleware()) r.Use(limiter.RateLimitMiddleware())
} }
// Add the errorInterceptor middleware
//r.Use(middleware.ErrorInterceptor)
for _, route := range gateway.Routes { for _, route := range gateway.Routes {
if route.Path != "" { if route.Path != "" {
blM := middleware.BlockListMiddleware{ blM := middleware.BlockListMiddleware{
@@ -63,7 +65,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
} else { } else {
//Check Authentication middleware //Check Authentication middleware
switch rMiddleware.Type { switch rMiddleware.Type {
case "basic": case basicAuth, "basic":
basicAuth, err := ToBasicAuth(rMiddleware.Rule) basicAuth, err := ToBasicAuth(rMiddleware.Rule)
if err != nil { if err != nil {
logger.Error("Error: %s", err.Error()) 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
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
} }
case "jwt": case jwtAuth, "jwt":
jwt, err := ToJWTRuler(rMiddleware.Rule) jwt, err := ToJWTRuler(rMiddleware.Rule)
if err != nil { if err != nil {
logger.Error("Error: %s", err.Error()) logger.Error("Error: %s", err.Error())
@@ -98,6 +100,9 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler secureRouter.PathPrefix("").Handler(proxyRoute.ProxyHandler()) // Proxy handler
} }
case OAuth, "auth0":
logger.Error("OAuth is not yet implemented")
logger.Info("Auth middleware ignored")
default: default:
logger.Error("Unknown middleware type %s", rMiddleware.Type) logger.Error("Unknown middleware type %s", rMiddleware.Type)
@@ -129,11 +134,16 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
} }
} else { } else {
logger.Error("Error, path is empty in route %s", route.Name) 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 // Apply global Cors middlewares
r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware r.Use(CORSHandler(gateway.Cors)) // Apply CORS middleware
// Apply errorInterceptor middleware
interceptErrors := middleware.InterceptErrors{
Errors: gateway.InterceptErrors,
}
r.Use(interceptErrors.ErrorInterceptor)
return r return r
} }

View File

@@ -1,3 +1,7 @@
package pkg package pkg
const ConfigFile = "/config/goma.yml" const ConfigFile = "/config/goma.yml"
const accessControlAllowOrigin = "Access-Control-Allow-Origin"
const basicAuth = "basicAuth"
const jwtAuth = "jwtAuth"
const OAuth = "OAuth"